@fern-api/replay 0.6.2 → 0.8.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/index.cjs CHANGED
@@ -133,6 +133,9 @@ var init_GitClient = __esm({
133
133
  return false;
134
134
  }
135
135
  }
136
+ async getCommitBody(commitSha) {
137
+ return this.exec(["log", "-1", "--format=%B", commitSha]);
138
+ }
136
139
  getRepoPath() {
137
140
  return this.repoPath;
138
141
  }
@@ -157,6 +160,9 @@ __export(index_exports, {
157
160
  forget: () => forget,
158
161
  isGenerationCommit: () => isGenerationCommit,
159
162
  isReplayCommit: () => isReplayCommit,
163
+ isRevertCommit: () => isRevertCommit,
164
+ parseRevertedMessage: () => parseRevertedMessage,
165
+ parseRevertedSha: () => parseRevertedSha,
160
166
  reset: () => reset,
161
167
  resolve: () => resolve,
162
168
  status: () => status,
@@ -182,6 +188,17 @@ function isGenerationCommit(commit) {
182
188
  function isReplayCommit(commit) {
183
189
  return commit.message.startsWith("[fern-replay]");
184
190
  }
191
+ function isRevertCommit(message) {
192
+ return message.startsWith('Revert "');
193
+ }
194
+ function parseRevertedSha(fullBody) {
195
+ const match = fullBody.match(/This reverts commit ([0-9a-f]{40})\./);
196
+ return match?.[1];
197
+ }
198
+ function parseRevertedMessage(subject) {
199
+ const match = subject.match(/^Revert "(.+)"$/);
200
+ return match?.[1];
201
+ }
185
202
 
186
203
  // src/LockfileManager.ts
187
204
  var import_node_fs = require("fs");
@@ -273,6 +290,26 @@ var LockfileManager = class {
273
290
  this.ensureLoaded();
274
291
  this.lock.patches = [];
275
292
  }
293
+ getUnresolvedPatches() {
294
+ this.ensureLoaded();
295
+ return this.lock.patches.filter((p) => p.status === "unresolved");
296
+ }
297
+ getResolvingPatches() {
298
+ this.ensureLoaded();
299
+ return this.lock.patches.filter((p) => p.status === "resolving");
300
+ }
301
+ markPatchUnresolved(patchId) {
302
+ this.updatePatch(patchId, { status: "unresolved" });
303
+ }
304
+ markPatchResolved(patchId, updates) {
305
+ this.ensureLoaded();
306
+ const patch = this.lock.patches.find((p) => p.id === patchId);
307
+ if (!patch) {
308
+ throw new Error(`Patch not found: ${patchId}`);
309
+ }
310
+ delete patch.status;
311
+ Object.assign(patch, updates);
312
+ }
276
313
  getPatches() {
277
314
  this.ensureLoaded();
278
315
  return this.lock.patches;
@@ -324,14 +361,14 @@ var ReplayDetector = class {
324
361
  const lock = this.lockManager.read();
325
362
  const lastGen = this.getLastGeneration(lock);
326
363
  if (!lastGen) {
327
- return [];
364
+ return { patches: [], revertedPatchIds: [] };
328
365
  }
329
366
  const exists = await this.git.commitExists(lastGen.commit_sha);
330
367
  if (!exists) {
331
368
  this.warnings.push(
332
369
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Skipping new patch detection. Existing lockfile patches will still be applied.`
333
370
  );
334
- return [];
371
+ return { patches: [], revertedPatchIds: [] };
335
372
  }
336
373
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
337
374
  if (!isAncestor) {
@@ -345,7 +382,7 @@ var ReplayDetector = class {
345
382
  this.sdkOutputDir
346
383
  ]);
347
384
  if (!log.trim()) {
348
- return [];
385
+ return { patches: [], revertedPatchIds: [] };
349
386
  }
350
387
  const commits = this.parseGitLog(log);
351
388
  const newPatches = [];
@@ -381,7 +418,60 @@ var ReplayDetector = class {
381
418
  patch_content: patchContent
382
419
  });
383
420
  }
384
- return newPatches.reverse();
421
+ newPatches.reverse();
422
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
423
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
424
+ for (let i = 0; i < newPatches.length; i++) {
425
+ const patch = newPatches[i];
426
+ if (!isRevertCommit(patch.original_message)) continue;
427
+ let body = "";
428
+ try {
429
+ body = await this.git.getCommitBody(patch.original_commit);
430
+ } catch {
431
+ }
432
+ const revertedSha = parseRevertedSha(body);
433
+ const revertedMessage = parseRevertedMessage(patch.original_message);
434
+ let matchedExisting = false;
435
+ if (revertedSha) {
436
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
437
+ if (existing) {
438
+ revertedPatchIdSet.add(existing.id);
439
+ revertIndicesToRemove.add(i);
440
+ matchedExisting = true;
441
+ }
442
+ }
443
+ if (!matchedExisting && revertedMessage) {
444
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
445
+ if (existing) {
446
+ revertedPatchIdSet.add(existing.id);
447
+ revertIndicesToRemove.add(i);
448
+ matchedExisting = true;
449
+ }
450
+ }
451
+ if (matchedExisting) continue;
452
+ let matchedNew = false;
453
+ if (revertedSha) {
454
+ const idx = newPatches.findIndex(
455
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
456
+ );
457
+ if (idx !== -1) {
458
+ revertIndicesToRemove.add(i);
459
+ revertIndicesToRemove.add(idx);
460
+ matchedNew = true;
461
+ }
462
+ }
463
+ if (!matchedNew && revertedMessage) {
464
+ const idx = newPatches.findIndex(
465
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
466
+ );
467
+ if (idx !== -1) {
468
+ revertIndicesToRemove.add(i);
469
+ revertIndicesToRemove.add(idx);
470
+ }
471
+ }
472
+ }
473
+ const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
474
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
385
475
  }
386
476
  /**
387
477
  * Compute content hash for deduplication.
@@ -392,31 +482,34 @@ var ReplayDetector = class {
392
482
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
393
483
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
394
484
  }
395
- /** Detect patches via tree diff for non-linear history. Returns a composite patch. */
485
+ /**
486
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
487
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
488
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
489
+ */
396
490
  async detectPatchesViaTreeDiff(lastGen) {
397
491
  const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
398
492
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
399
- if (files.length === 0) return [];
493
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
400
494
  const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
401
- if (!diff.trim()) return [];
495
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
402
496
  const contentHash = this.computeContentHash(diff);
403
497
  const lock = this.lockManager.read();
404
498
  if (lock.patches.some((p) => p.content_hash === contentHash)) {
405
- return [];
499
+ return { patches: [], revertedPatchIds: [] };
406
500
  }
407
501
  const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
408
- return [
409
- {
410
- id: `patch-composite-${headSha.slice(0, 8)}`,
411
- content_hash: contentHash,
412
- original_commit: headSha,
413
- original_message: "Customer customizations (composite)",
414
- original_author: "composite",
415
- base_generation: lastGen.commit_sha,
416
- files,
417
- patch_content: diff
418
- }
419
- ];
502
+ const compositePatch = {
503
+ id: `patch-composite-${headSha.slice(0, 8)}`,
504
+ content_hash: contentHash,
505
+ original_commit: headSha,
506
+ original_message: "Customer customizations (composite)",
507
+ original_author: "composite",
508
+ base_generation: lastGen.commit_sha,
509
+ files,
510
+ patch_content: diff
511
+ };
512
+ return { patches: [compositePatch], revertedPatchIds: [] };
420
513
  }
421
514
  parseGitLog(log) {
422
515
  return log.trim().split("\n").map((line) => {
@@ -973,12 +1066,12 @@ CLI Version: ${options.cliVersion}`;
973
1066
  await this.git.exec(["commit", "-m", fullMessage]);
974
1067
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
975
1068
  }
976
- async commitReplay(_patchCount, patches) {
1069
+ async commitReplay(_patchCount, patches, message) {
977
1070
  await this.stageAll();
978
1071
  if (!await this.hasStagedChanges()) {
979
1072
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
980
1073
  }
981
- let fullMessage = `[fern-replay] Applied customizations`;
1074
+ let fullMessage = message ?? `[fern-replay] Applied customizations`;
982
1075
  if (patches && patches.length > 0) {
983
1076
  fullMessage += "\n\nPatches replayed:";
984
1077
  for (const patch of patches) {
@@ -1018,6 +1111,36 @@ var import_node_fs2 = require("fs");
1018
1111
  var import_node_path3 = require("path");
1019
1112
  var import_minimatch2 = require("minimatch");
1020
1113
  init_GitClient();
1114
+
1115
+ // src/conflict-utils.ts
1116
+ function stripConflictMarkers(content) {
1117
+ const lines = content.split("\n");
1118
+ const result = [];
1119
+ let inConflict = false;
1120
+ let inOurs = false;
1121
+ for (const line of lines) {
1122
+ if (line.startsWith("<<<<<<< ")) {
1123
+ inConflict = true;
1124
+ inOurs = true;
1125
+ continue;
1126
+ }
1127
+ if (inConflict && line === "=======") {
1128
+ inOurs = false;
1129
+ continue;
1130
+ }
1131
+ if (inConflict && line.startsWith(">>>>>>> ")) {
1132
+ inConflict = false;
1133
+ inOurs = false;
1134
+ continue;
1135
+ }
1136
+ if (!inConflict || inOurs) {
1137
+ result.push(line);
1138
+ }
1139
+ }
1140
+ return result.join("\n");
1141
+ }
1142
+
1143
+ // src/ReplayService.ts
1021
1144
  var ReplayService = class {
1022
1145
  git;
1023
1146
  detector;
@@ -1073,13 +1196,27 @@ var ReplayService = class {
1073
1196
  this.lockManager.initializeInMemory(record);
1074
1197
  } else {
1075
1198
  this.lockManager.read();
1199
+ const unresolvedPatches = [
1200
+ ...this.lockManager.getUnresolvedPatches(),
1201
+ ...this.lockManager.getResolvingPatches()
1202
+ ];
1076
1203
  this.lockManager.addGeneration(record);
1077
1204
  this.lockManager.clearPatches();
1205
+ for (const patch of unresolvedPatches) {
1206
+ this.lockManager.addPatch(patch);
1207
+ }
1078
1208
  }
1079
1209
  this.lockManager.save();
1080
1210
  try {
1081
- const redetectedPatches = await this.detector.detectNewPatches();
1211
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
1082
1212
  if (redetectedPatches.length > 0) {
1213
+ const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
1214
+ const currentPatches = this.lockManager.getPatches();
1215
+ for (const patch of currentPatches) {
1216
+ if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
1217
+ this.lockManager.removePatch(patch.id);
1218
+ }
1219
+ }
1083
1220
  for (const patch of redetectedPatches) {
1084
1221
  this.lockManager.addPatch(patch);
1085
1222
  }
@@ -1162,7 +1299,7 @@ var ReplayService = class {
1162
1299
  };
1163
1300
  }
1164
1301
  async handleNoPatchesRegeneration(options) {
1165
- const newPatches = await this.detector.detectNewPatches();
1302
+ const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1166
1303
  const warnings = [...this.detector.warnings];
1167
1304
  if (options?.dryRun) {
1168
1305
  return {
@@ -1171,11 +1308,18 @@ var ReplayService = class {
1171
1308
  patchesApplied: 0,
1172
1309
  patchesWithConflicts: 0,
1173
1310
  patchesSkipped: 0,
1311
+ patchesReverted: revertedPatchIds.length,
1174
1312
  conflicts: [],
1175
1313
  wouldApply: newPatches,
1176
1314
  warnings: warnings.length > 0 ? warnings : void 0
1177
1315
  };
1178
1316
  }
1317
+ for (const id of revertedPatchIds) {
1318
+ try {
1319
+ this.lockManager.removePatch(id);
1320
+ } catch {
1321
+ }
1322
+ }
1179
1323
  const commitOpts = options ? {
1180
1324
  cliVersion: options.cliVersion ?? "unknown",
1181
1325
  generatorVersions: options.generatorVersions ?? {},
@@ -1187,6 +1331,12 @@ var ReplayService = class {
1187
1331
  let results = [];
1188
1332
  if (newPatches.length > 0) {
1189
1333
  results = await this.applicator.applyPatches(newPatches);
1334
+ this.revertConflictingFiles(results);
1335
+ for (const result of results) {
1336
+ if (result.status === "conflict") {
1337
+ result.patch.status = "unresolved";
1338
+ }
1339
+ }
1190
1340
  }
1191
1341
  const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
1192
1342
  for (const patch of newPatches) {
@@ -1197,20 +1347,18 @@ var ReplayService = class {
1197
1347
  this.lockManager.save();
1198
1348
  if (newPatches.length > 0) {
1199
1349
  if (!options?.stageOnly) {
1200
- const appliedCount = results.filter((r) => r.status === "applied" || r.status === "conflict").length;
1201
- if (appliedCount > 0) {
1202
- await this.committer.commitReplay(appliedCount, newPatches);
1203
- }
1350
+ const appliedCount = results.filter((r) => r.status === "applied").length;
1351
+ await this.committer.commitReplay(appliedCount, newPatches);
1204
1352
  } else {
1205
1353
  await this.committer.stageAll();
1206
1354
  }
1207
1355
  }
1208
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts);
1356
+ return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
1209
1357
  }
1210
1358
  async handleNormalRegeneration(options) {
1211
1359
  if (options?.dryRun) {
1212
1360
  const existingPatches2 = this.lockManager.getPatches();
1213
- const newPatches2 = await this.detector.detectNewPatches();
1361
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
1214
1362
  const warnings2 = [...this.detector.warnings];
1215
1363
  const allPatches2 = [...existingPatches2, ...newPatches2];
1216
1364
  return {
@@ -1219,13 +1367,19 @@ var ReplayService = class {
1219
1367
  patchesApplied: 0,
1220
1368
  patchesWithConflicts: 0,
1221
1369
  patchesSkipped: 0,
1370
+ patchesReverted: dryRunReverted.length,
1222
1371
  conflicts: [],
1223
1372
  wouldApply: allPatches2,
1224
1373
  warnings: warnings2.length > 0 ? warnings2 : void 0
1225
1374
  };
1226
1375
  }
1227
1376
  let existingPatches = this.lockManager.getPatches();
1377
+ const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
1228
1378
  const preRebaseCounts = await this.preGenerationRebase(existingPatches);
1379
+ const postRebasePatchIds = new Set(this.lockManager.getPatches().map((p) => p.id));
1380
+ const removedByPreRebase = existingPatches.filter(
1381
+ (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
1382
+ );
1229
1383
  existingPatches = this.lockManager.getPatches();
1230
1384
  const seenHashes = /* @__PURE__ */ new Set();
1231
1385
  for (const p of existingPatches) {
@@ -1236,8 +1390,28 @@ var ReplayService = class {
1236
1390
  }
1237
1391
  }
1238
1392
  existingPatches = this.lockManager.getPatches();
1239
- const newPatches = await this.detector.detectNewPatches();
1393
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1240
1394
  const warnings = [...this.detector.warnings];
1395
+ if (removedByPreRebase.length > 0) {
1396
+ const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
1397
+ const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
1398
+ newPatches = newPatches.filter((p) => {
1399
+ if (removedOriginalCommits.has(p.original_commit)) return false;
1400
+ if (isRevertCommit(p.original_message)) {
1401
+ const revertedMsg = parseRevertedMessage(p.original_message);
1402
+ if (revertedMsg && removedOriginalMessages.has(revertedMsg)) return false;
1403
+ }
1404
+ return true;
1405
+ });
1406
+ }
1407
+ for (const id of revertedPatchIds) {
1408
+ try {
1409
+ this.lockManager.removePatch(id);
1410
+ } catch {
1411
+ }
1412
+ }
1413
+ const revertedSet = new Set(revertedPatchIds);
1414
+ existingPatches = existingPatches.filter((p) => !revertedSet.has(p.id));
1241
1415
  const allPatches = [...existingPatches, ...newPatches];
1242
1416
  const commitOpts = options ? {
1243
1417
  cliVersion: options.cliVersion ?? "unknown",
@@ -1248,20 +1422,32 @@ var ReplayService = class {
1248
1422
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
1249
1423
  this.lockManager.addGeneration(genRecord);
1250
1424
  const results = await this.applicator.applyPatches(allPatches);
1425
+ this.revertConflictingFiles(results);
1426
+ for (const result of results) {
1427
+ if (result.status === "conflict") {
1428
+ result.patch.status = "unresolved";
1429
+ }
1430
+ }
1251
1431
  const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
1252
1432
  for (const patch of newPatches) {
1253
1433
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
1254
1434
  this.lockManager.addPatch(patch);
1255
1435
  }
1256
1436
  }
1437
+ for (const result of results) {
1438
+ if (result.status === "conflict") {
1439
+ try {
1440
+ this.lockManager.markPatchUnresolved(result.patch.id);
1441
+ } catch {
1442
+ }
1443
+ }
1444
+ }
1257
1445
  this.lockManager.save();
1258
1446
  if (options?.stageOnly) {
1259
1447
  await this.committer.stageAll();
1260
1448
  } else {
1261
- const appliedCount = results.filter((r) => r.status === "applied" || r.status === "conflict").length;
1262
- if (appliedCount > 0) {
1263
- await this.committer.commitReplay(appliedCount, allPatches);
1264
- }
1449
+ const appliedCount = results.filter((r) => r.status === "applied").length;
1450
+ await this.committer.commitReplay(appliedCount, allPatches);
1265
1451
  }
1266
1452
  return this.buildReport(
1267
1453
  "normal-regeneration",
@@ -1270,7 +1456,8 @@ var ReplayService = class {
1270
1456
  options,
1271
1457
  warnings,
1272
1458
  rebaseCounts,
1273
- preRebaseCounts
1459
+ preRebaseCounts,
1460
+ revertedPatchIds.length
1274
1461
  );
1275
1462
  }
1276
1463
  /**
@@ -1409,6 +1596,10 @@ var ReplayService = class {
1409
1596
  let conflictAbsorbed = 0;
1410
1597
  let contentRefreshed = 0;
1411
1598
  for (const patch of patches) {
1599
+ if (patch.status != null) {
1600
+ delete patch.status;
1601
+ continue;
1602
+ }
1412
1603
  if (patch.base_generation === currentGen) {
1413
1604
  try {
1414
1605
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -1459,12 +1650,31 @@ var ReplayService = class {
1459
1650
  }
1460
1651
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
1461
1652
  }
1653
+ /**
1654
+ * After applyPatches(), strip conflict markers from conflicting files
1655
+ * so only clean content is committed. Keeps the Generated (OURS) side.
1656
+ */
1657
+ revertConflictingFiles(results) {
1658
+ for (const result of results) {
1659
+ if (result.status !== "conflict" || !result.fileResults) continue;
1660
+ for (const fileResult of result.fileResults) {
1661
+ if (fileResult.status !== "conflict") continue;
1662
+ const filePath = (0, import_node_path3.join)(this.outputDir, fileResult.file);
1663
+ try {
1664
+ const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
1665
+ const stripped = stripConflictMarkers(content);
1666
+ (0, import_node_fs2.writeFileSync)(filePath, stripped);
1667
+ } catch {
1668
+ }
1669
+ }
1670
+ }
1671
+ }
1462
1672
  readFernignorePatterns() {
1463
1673
  const fernignorePath = (0, import_node_path3.join)(this.outputDir, ".fernignore");
1464
1674
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
1465
1675
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
1466
1676
  }
1467
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts) {
1677
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
1468
1678
  const conflictResults = results.filter((r) => r.status === "conflict");
1469
1679
  const conflictDetails = conflictResults.map((r) => {
1470
1680
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -1489,10 +1699,17 @@ var ReplayService = class {
1489
1699
  patchesRepointed: rebaseCounts && rebaseCounts.repointed > 0 ? rebaseCounts.repointed : void 0,
1490
1700
  patchesContentRebased: rebaseCounts && rebaseCounts.contentRebased > 0 ? rebaseCounts.contentRebased : void 0,
1491
1701
  patchesKeptAsUserOwned: rebaseCounts && rebaseCounts.keptAsUserOwned > 0 ? rebaseCounts.keptAsUserOwned : void 0,
1702
+ patchesReverted: patchesReverted && patchesReverted > 0 ? patchesReverted : void 0,
1492
1703
  patchesConflictResolved: preRebaseCounts && preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed > 0 ? preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed : void 0,
1493
1704
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
1494
1705
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
1495
1706
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
1707
+ unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
1708
+ patchId: r.patch.id,
1709
+ patchMessage: r.patch.original_message,
1710
+ files: r.patch.files,
1711
+ conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
1712
+ })) : void 0,
1496
1713
  wouldApply: options?.dryRun ? patches : void 0,
1497
1714
  warnings: warnings && warnings.length > 0 ? warnings : void 0
1498
1715
  };
@@ -1529,7 +1746,7 @@ var FernignoreMigrator = class {
1529
1746
  async analyzeMigration() {
1530
1747
  const patterns = this.readFernignorePatterns();
1531
1748
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
1532
- const patches = await detector.detectNewPatches();
1749
+ const { patches } = await detector.detectNewPatches();
1533
1750
  const trackedByBoth = [];
1534
1751
  const fernignoreOnly = [];
1535
1752
  const commitsOnly = [];
@@ -1654,7 +1871,7 @@ var FernignoreMigrator = class {
1654
1871
  async migrate() {
1655
1872
  const analysis = await this.analyzeMigration();
1656
1873
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
1657
- const patches = await detector.detectNewPatches();
1874
+ const { patches } = await detector.detectNewPatches();
1658
1875
  const warnings = [];
1659
1876
  let patchesCreated = 0;
1660
1877
  for (const patch of patches) {
@@ -1954,17 +2171,82 @@ async function resolve(outputDir, options) {
1954
2171
  return { success: false, reason: "no-patches" };
1955
2172
  }
1956
2173
  const git = new GitClient(outputDir);
2174
+ const unresolvedPatches = lockManager.getUnresolvedPatches();
2175
+ const resolvingPatches = lockManager.getResolvingPatches();
2176
+ if (unresolvedPatches.length > 0) {
2177
+ const applicator = new ReplayApplicator(git, lockManager, outputDir);
2178
+ await applicator.applyPatches(unresolvedPatches);
2179
+ const markerFiles = await findConflictMarkerFiles(git);
2180
+ if (markerFiles.length > 0) {
2181
+ for (const patch of unresolvedPatches) {
2182
+ lockManager.updatePatch(patch.id, { status: "resolving" });
2183
+ }
2184
+ lockManager.save();
2185
+ return {
2186
+ success: false,
2187
+ reason: "conflicts-applied",
2188
+ unresolvedFiles: markerFiles,
2189
+ phase: "applied",
2190
+ patchesApplied: unresolvedPatches.length
2191
+ };
2192
+ }
2193
+ }
1957
2194
  if (options?.checkMarkers !== false) {
1958
- const markerFiles = await git.exec(["grep", "-l", "<<<<<<<", "--", "."]).catch(() => "");
1959
- if (markerFiles.trim()) {
1960
- const files = markerFiles.trim().split("\n").filter(Boolean);
1961
- return { success: false, reason: "unresolved-conflicts", unresolvedFiles: files };
2195
+ const currentMarkerFiles = await findConflictMarkerFiles(git);
2196
+ if (currentMarkerFiles.length > 0) {
2197
+ return { success: false, reason: "unresolved-conflicts", unresolvedFiles: currentMarkerFiles };
2198
+ }
2199
+ }
2200
+ const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
2201
+ if (patchesToCommit.length > 0) {
2202
+ const currentGen = lock.current_generation;
2203
+ const detector = new ReplayDetector(git, lockManager, outputDir);
2204
+ let patchesResolved = 0;
2205
+ for (const patch of patchesToCommit) {
2206
+ const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
2207
+ if (!diff || !diff.trim()) {
2208
+ lockManager.removePatch(patch.id);
2209
+ continue;
2210
+ }
2211
+ const newContentHash = detector.computeContentHash(diff);
2212
+ const changedFiles = await getChangedFiles(git, currentGen, patch.files);
2213
+ lockManager.markPatchResolved(patch.id, {
2214
+ patch_content: diff,
2215
+ content_hash: newContentHash,
2216
+ base_generation: currentGen,
2217
+ files: changedFiles
2218
+ });
2219
+ patchesResolved++;
1962
2220
  }
2221
+ lockManager.save();
2222
+ const committer2 = new ReplayCommitter(git, outputDir);
2223
+ await committer2.stageAll();
2224
+ const commitSha2 = await committer2.commitReplay(
2225
+ lock.patches.length,
2226
+ lock.patches,
2227
+ "[fern-replay] Resolved conflicts"
2228
+ );
2229
+ return {
2230
+ success: true,
2231
+ commitSha: commitSha2,
2232
+ phase: "committed",
2233
+ patchesResolved
2234
+ };
1963
2235
  }
1964
2236
  const committer = new ReplayCommitter(git, outputDir);
1965
2237
  await committer.stageAll();
1966
2238
  const commitSha = await committer.commitReplay(lock.patches.length, lock.patches);
1967
- return { success: true, commitSha };
2239
+ return { success: true, commitSha, phase: "committed" };
2240
+ }
2241
+ async function findConflictMarkerFiles(git) {
2242
+ const output = await git.exec(["grep", "-l", "<<<<<<<", "--", "."]).catch(() => "");
2243
+ return output.trim() ? output.trim().split("\n").filter(Boolean) : [];
2244
+ }
2245
+ async function getChangedFiles(git, currentGen, files) {
2246
+ const filesOutput = await git.exec(["diff", "--name-only", currentGen, "--", ...files]).catch(() => null);
2247
+ if (!filesOutput || !filesOutput.trim()) return files;
2248
+ const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
2249
+ return changed.length > 0 ? changed : files;
1968
2250
  }
1969
2251
 
1970
2252
  // src/commands/status.ts
@@ -2006,6 +2288,9 @@ function status(outputDir) {
2006
2288
  forget,
2007
2289
  isGenerationCommit,
2008
2290
  isReplayCommit,
2291
+ isRevertCommit,
2292
+ parseRevertedMessage,
2293
+ parseRevertedSha,
2009
2294
  reset,
2010
2295
  resolve,
2011
2296
  status,