@fern-api/replay 0.7.0 → 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");
@@ -344,14 +361,14 @@ var ReplayDetector = class {
344
361
  const lock = this.lockManager.read();
345
362
  const lastGen = this.getLastGeneration(lock);
346
363
  if (!lastGen) {
347
- return [];
364
+ return { patches: [], revertedPatchIds: [] };
348
365
  }
349
366
  const exists = await this.git.commitExists(lastGen.commit_sha);
350
367
  if (!exists) {
351
368
  this.warnings.push(
352
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.`
353
370
  );
354
- return [];
371
+ return { patches: [], revertedPatchIds: [] };
355
372
  }
356
373
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
357
374
  if (!isAncestor) {
@@ -365,7 +382,7 @@ var ReplayDetector = class {
365
382
  this.sdkOutputDir
366
383
  ]);
367
384
  if (!log.trim()) {
368
- return [];
385
+ return { patches: [], revertedPatchIds: [] };
369
386
  }
370
387
  const commits = this.parseGitLog(log);
371
388
  const newPatches = [];
@@ -401,7 +418,60 @@ var ReplayDetector = class {
401
418
  patch_content: patchContent
402
419
  });
403
420
  }
404
- 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] };
405
475
  }
406
476
  /**
407
477
  * Compute content hash for deduplication.
@@ -412,31 +482,34 @@ var ReplayDetector = class {
412
482
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
413
483
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
414
484
  }
415
- /** 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
+ */
416
490
  async detectPatchesViaTreeDiff(lastGen) {
417
491
  const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
418
492
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
419
- if (files.length === 0) return [];
493
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
420
494
  const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
421
- if (!diff.trim()) return [];
495
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
422
496
  const contentHash = this.computeContentHash(diff);
423
497
  const lock = this.lockManager.read();
424
498
  if (lock.patches.some((p) => p.content_hash === contentHash)) {
425
- return [];
499
+ return { patches: [], revertedPatchIds: [] };
426
500
  }
427
501
  const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
428
- return [
429
- {
430
- id: `patch-composite-${headSha.slice(0, 8)}`,
431
- content_hash: contentHash,
432
- original_commit: headSha,
433
- original_message: "Customer customizations (composite)",
434
- original_author: "composite",
435
- base_generation: lastGen.commit_sha,
436
- files,
437
- patch_content: diff
438
- }
439
- ];
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: [] };
440
513
  }
441
514
  parseGitLog(log) {
442
515
  return log.trim().split("\n").map((line) => {
@@ -1038,6 +1111,36 @@ var import_node_fs2 = require("fs");
1038
1111
  var import_node_path3 = require("path");
1039
1112
  var import_minimatch2 = require("minimatch");
1040
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
1041
1144
  var ReplayService = class {
1042
1145
  git;
1043
1146
  detector;
@@ -1105,7 +1208,7 @@ var ReplayService = class {
1105
1208
  }
1106
1209
  this.lockManager.save();
1107
1210
  try {
1108
- const redetectedPatches = await this.detector.detectNewPatches();
1211
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
1109
1212
  if (redetectedPatches.length > 0) {
1110
1213
  const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
1111
1214
  const currentPatches = this.lockManager.getPatches();
@@ -1196,7 +1299,7 @@ var ReplayService = class {
1196
1299
  };
1197
1300
  }
1198
1301
  async handleNoPatchesRegeneration(options) {
1199
- const newPatches = await this.detector.detectNewPatches();
1302
+ const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1200
1303
  const warnings = [...this.detector.warnings];
1201
1304
  if (options?.dryRun) {
1202
1305
  return {
@@ -1205,11 +1308,18 @@ var ReplayService = class {
1205
1308
  patchesApplied: 0,
1206
1309
  patchesWithConflicts: 0,
1207
1310
  patchesSkipped: 0,
1311
+ patchesReverted: revertedPatchIds.length,
1208
1312
  conflicts: [],
1209
1313
  wouldApply: newPatches,
1210
1314
  warnings: warnings.length > 0 ? warnings : void 0
1211
1315
  };
1212
1316
  }
1317
+ for (const id of revertedPatchIds) {
1318
+ try {
1319
+ this.lockManager.removePatch(id);
1320
+ } catch {
1321
+ }
1322
+ }
1213
1323
  const commitOpts = options ? {
1214
1324
  cliVersion: options.cliVersion ?? "unknown",
1215
1325
  generatorVersions: options.generatorVersions ?? {},
@@ -1243,12 +1353,12 @@ var ReplayService = class {
1243
1353
  await this.committer.stageAll();
1244
1354
  }
1245
1355
  }
1246
- 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);
1247
1357
  }
1248
1358
  async handleNormalRegeneration(options) {
1249
1359
  if (options?.dryRun) {
1250
1360
  const existingPatches2 = this.lockManager.getPatches();
1251
- const newPatches2 = await this.detector.detectNewPatches();
1361
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
1252
1362
  const warnings2 = [...this.detector.warnings];
1253
1363
  const allPatches2 = [...existingPatches2, ...newPatches2];
1254
1364
  return {
@@ -1257,13 +1367,19 @@ var ReplayService = class {
1257
1367
  patchesApplied: 0,
1258
1368
  patchesWithConflicts: 0,
1259
1369
  patchesSkipped: 0,
1370
+ patchesReverted: dryRunReverted.length,
1260
1371
  conflicts: [],
1261
1372
  wouldApply: allPatches2,
1262
1373
  warnings: warnings2.length > 0 ? warnings2 : void 0
1263
1374
  };
1264
1375
  }
1265
1376
  let existingPatches = this.lockManager.getPatches();
1377
+ const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
1266
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
+ );
1267
1383
  existingPatches = this.lockManager.getPatches();
1268
1384
  const seenHashes = /* @__PURE__ */ new Set();
1269
1385
  for (const p of existingPatches) {
@@ -1274,8 +1390,28 @@ var ReplayService = class {
1274
1390
  }
1275
1391
  }
1276
1392
  existingPatches = this.lockManager.getPatches();
1277
- const newPatches = await this.detector.detectNewPatches();
1393
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1278
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));
1279
1415
  const allPatches = [...existingPatches, ...newPatches];
1280
1416
  const commitOpts = options ? {
1281
1417
  cliVersion: options.cliVersion ?? "unknown",
@@ -1320,7 +1456,8 @@ var ReplayService = class {
1320
1456
  options,
1321
1457
  warnings,
1322
1458
  rebaseCounts,
1323
- preRebaseCounts
1459
+ preRebaseCounts,
1460
+ revertedPatchIds.length
1324
1461
  );
1325
1462
  }
1326
1463
  /**
@@ -1513,36 +1650,6 @@ var ReplayService = class {
1513
1650
  }
1514
1651
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
1515
1652
  }
1516
- /**
1517
- * Strip conflict markers from file content, keeping the OURS (Generated) side.
1518
- * Preserves clean patches' non-conflicting changes on shared files.
1519
- */
1520
- stripConflictMarkers(content) {
1521
- const lines = content.split("\n");
1522
- const result = [];
1523
- let inConflict = false;
1524
- let inOurs = false;
1525
- for (const line of lines) {
1526
- if (line.startsWith("<<<<<<< ")) {
1527
- inConflict = true;
1528
- inOurs = true;
1529
- continue;
1530
- }
1531
- if (inConflict && line === "=======") {
1532
- inOurs = false;
1533
- continue;
1534
- }
1535
- if (inConflict && line.startsWith(">>>>>>> ")) {
1536
- inConflict = false;
1537
- inOurs = false;
1538
- continue;
1539
- }
1540
- if (!inConflict || inOurs) {
1541
- result.push(line);
1542
- }
1543
- }
1544
- return result.join("\n");
1545
- }
1546
1653
  /**
1547
1654
  * After applyPatches(), strip conflict markers from conflicting files
1548
1655
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -1555,7 +1662,7 @@ var ReplayService = class {
1555
1662
  const filePath = (0, import_node_path3.join)(this.outputDir, fileResult.file);
1556
1663
  try {
1557
1664
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
1558
- const stripped = this.stripConflictMarkers(content);
1665
+ const stripped = stripConflictMarkers(content);
1559
1666
  (0, import_node_fs2.writeFileSync)(filePath, stripped);
1560
1667
  } catch {
1561
1668
  }
@@ -1567,7 +1674,7 @@ var ReplayService = class {
1567
1674
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
1568
1675
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
1569
1676
  }
1570
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts) {
1677
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
1571
1678
  const conflictResults = results.filter((r) => r.status === "conflict");
1572
1679
  const conflictDetails = conflictResults.map((r) => {
1573
1680
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -1592,6 +1699,7 @@ var ReplayService = class {
1592
1699
  patchesRepointed: rebaseCounts && rebaseCounts.repointed > 0 ? rebaseCounts.repointed : void 0,
1593
1700
  patchesContentRebased: rebaseCounts && rebaseCounts.contentRebased > 0 ? rebaseCounts.contentRebased : void 0,
1594
1701
  patchesKeptAsUserOwned: rebaseCounts && rebaseCounts.keptAsUserOwned > 0 ? rebaseCounts.keptAsUserOwned : void 0,
1702
+ patchesReverted: patchesReverted && patchesReverted > 0 ? patchesReverted : void 0,
1595
1703
  patchesConflictResolved: preRebaseCounts && preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed > 0 ? preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed : void 0,
1596
1704
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
1597
1705
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
@@ -1638,7 +1746,7 @@ var FernignoreMigrator = class {
1638
1746
  async analyzeMigration() {
1639
1747
  const patterns = this.readFernignorePatterns();
1640
1748
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
1641
- const patches = await detector.detectNewPatches();
1749
+ const { patches } = await detector.detectNewPatches();
1642
1750
  const trackedByBoth = [];
1643
1751
  const fernignoreOnly = [];
1644
1752
  const commitsOnly = [];
@@ -1763,7 +1871,7 @@ var FernignoreMigrator = class {
1763
1871
  async migrate() {
1764
1872
  const analysis = await this.analyzeMigration();
1765
1873
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
1766
- const patches = await detector.detectNewPatches();
1874
+ const { patches } = await detector.detectNewPatches();
1767
1875
  const warnings = [];
1768
1876
  let patchesCreated = 0;
1769
1877
  for (const patch of patches) {
@@ -2180,6 +2288,9 @@ function status(outputDir) {
2180
2288
  forget,
2181
2289
  isGenerationCommit,
2182
2290
  isReplayCommit,
2291
+ isRevertCommit,
2292
+ parseRevertedMessage,
2293
+ parseRevertedSha,
2183
2294
  reset,
2184
2295
  resolve,
2185
2296
  status,