@fern-api/replay 0.7.0 → 0.8.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,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 /^Revert ".+"$/.test(message);
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,63 @@ 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
+ if (!matchedExisting && !matchedNew) {
473
+ revertIndicesToRemove.add(i);
474
+ }
475
+ }
476
+ const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
477
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
405
478
  }
406
479
  /**
407
480
  * Compute content hash for deduplication.
@@ -412,31 +485,34 @@ var ReplayDetector = class {
412
485
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
413
486
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
414
487
  }
415
- /** Detect patches via tree diff for non-linear history. Returns a composite patch. */
488
+ /**
489
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
490
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
491
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
492
+ */
416
493
  async detectPatchesViaTreeDiff(lastGen) {
417
494
  const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
418
495
  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 [];
496
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
420
497
  const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
421
- if (!diff.trim()) return [];
498
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
422
499
  const contentHash = this.computeContentHash(diff);
423
500
  const lock = this.lockManager.read();
424
501
  if (lock.patches.some((p) => p.content_hash === contentHash)) {
425
- return [];
502
+ return { patches: [], revertedPatchIds: [] };
426
503
  }
427
504
  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
- ];
505
+ const compositePatch = {
506
+ id: `patch-composite-${headSha.slice(0, 8)}`,
507
+ content_hash: contentHash,
508
+ original_commit: headSha,
509
+ original_message: "Customer customizations (composite)",
510
+ original_author: "composite",
511
+ base_generation: lastGen.commit_sha,
512
+ files,
513
+ patch_content: diff
514
+ };
515
+ return { patches: [compositePatch], revertedPatchIds: [] };
440
516
  }
441
517
  parseGitLog(log) {
442
518
  return log.trim().split("\n").map((line) => {
@@ -1038,6 +1114,36 @@ var import_node_fs2 = require("fs");
1038
1114
  var import_node_path3 = require("path");
1039
1115
  var import_minimatch2 = require("minimatch");
1040
1116
  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
1041
1147
  var ReplayService = class {
1042
1148
  git;
1043
1149
  detector;
@@ -1105,7 +1211,7 @@ var ReplayService = class {
1105
1211
  }
1106
1212
  this.lockManager.save();
1107
1213
  try {
1108
- const redetectedPatches = await this.detector.detectNewPatches();
1214
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
1109
1215
  if (redetectedPatches.length > 0) {
1110
1216
  const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
1111
1217
  const currentPatches = this.lockManager.getPatches();
@@ -1196,7 +1302,7 @@ var ReplayService = class {
1196
1302
  };
1197
1303
  }
1198
1304
  async handleNoPatchesRegeneration(options) {
1199
- const newPatches = await this.detector.detectNewPatches();
1305
+ const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1200
1306
  const warnings = [...this.detector.warnings];
1201
1307
  if (options?.dryRun) {
1202
1308
  return {
@@ -1205,11 +1311,18 @@ var ReplayService = class {
1205
1311
  patchesApplied: 0,
1206
1312
  patchesWithConflicts: 0,
1207
1313
  patchesSkipped: 0,
1314
+ patchesReverted: revertedPatchIds.length,
1208
1315
  conflicts: [],
1209
1316
  wouldApply: newPatches,
1210
1317
  warnings: warnings.length > 0 ? warnings : void 0
1211
1318
  };
1212
1319
  }
1320
+ for (const id of revertedPatchIds) {
1321
+ try {
1322
+ this.lockManager.removePatch(id);
1323
+ } catch {
1324
+ }
1325
+ }
1213
1326
  const commitOpts = options ? {
1214
1327
  cliVersion: options.cliVersion ?? "unknown",
1215
1328
  generatorVersions: options.generatorVersions ?? {},
@@ -1243,12 +1356,12 @@ var ReplayService = class {
1243
1356
  await this.committer.stageAll();
1244
1357
  }
1245
1358
  }
1246
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts);
1359
+ return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
1247
1360
  }
1248
1361
  async handleNormalRegeneration(options) {
1249
1362
  if (options?.dryRun) {
1250
1363
  const existingPatches2 = this.lockManager.getPatches();
1251
- const newPatches2 = await this.detector.detectNewPatches();
1364
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
1252
1365
  const warnings2 = [...this.detector.warnings];
1253
1366
  const allPatches2 = [...existingPatches2, ...newPatches2];
1254
1367
  return {
@@ -1257,13 +1370,19 @@ var ReplayService = class {
1257
1370
  patchesApplied: 0,
1258
1371
  patchesWithConflicts: 0,
1259
1372
  patchesSkipped: 0,
1373
+ patchesReverted: dryRunReverted.length,
1260
1374
  conflicts: [],
1261
1375
  wouldApply: allPatches2,
1262
1376
  warnings: warnings2.length > 0 ? warnings2 : void 0
1263
1377
  };
1264
1378
  }
1265
1379
  let existingPatches = this.lockManager.getPatches();
1380
+ const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
1266
1381
  const preRebaseCounts = await this.preGenerationRebase(existingPatches);
1382
+ const postRebasePatchIds = new Set(this.lockManager.getPatches().map((p) => p.id));
1383
+ const removedByPreRebase = existingPatches.filter(
1384
+ (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
1385
+ );
1267
1386
  existingPatches = this.lockManager.getPatches();
1268
1387
  const seenHashes = /* @__PURE__ */ new Set();
1269
1388
  for (const p of existingPatches) {
@@ -1274,8 +1393,28 @@ var ReplayService = class {
1274
1393
  }
1275
1394
  }
1276
1395
  existingPatches = this.lockManager.getPatches();
1277
- const newPatches = await this.detector.detectNewPatches();
1396
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
1278
1397
  const warnings = [...this.detector.warnings];
1398
+ if (removedByPreRebase.length > 0) {
1399
+ const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
1400
+ const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
1401
+ newPatches = newPatches.filter((p) => {
1402
+ if (removedOriginalCommits.has(p.original_commit)) return false;
1403
+ if (isRevertCommit(p.original_message)) {
1404
+ const revertedMsg = parseRevertedMessage(p.original_message);
1405
+ if (revertedMsg && removedOriginalMessages.has(revertedMsg)) return false;
1406
+ }
1407
+ return true;
1408
+ });
1409
+ }
1410
+ for (const id of revertedPatchIds) {
1411
+ try {
1412
+ this.lockManager.removePatch(id);
1413
+ } catch {
1414
+ }
1415
+ }
1416
+ const revertedSet = new Set(revertedPatchIds);
1417
+ existingPatches = existingPatches.filter((p) => !revertedSet.has(p.id));
1279
1418
  const allPatches = [...existingPatches, ...newPatches];
1280
1419
  const commitOpts = options ? {
1281
1420
  cliVersion: options.cliVersion ?? "unknown",
@@ -1320,7 +1459,8 @@ var ReplayService = class {
1320
1459
  options,
1321
1460
  warnings,
1322
1461
  rebaseCounts,
1323
- preRebaseCounts
1462
+ preRebaseCounts,
1463
+ revertedPatchIds.length
1324
1464
  );
1325
1465
  }
1326
1466
  /**
@@ -1513,36 +1653,6 @@ var ReplayService = class {
1513
1653
  }
1514
1654
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
1515
1655
  }
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
1656
  /**
1547
1657
  * After applyPatches(), strip conflict markers from conflicting files
1548
1658
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -1555,7 +1665,7 @@ var ReplayService = class {
1555
1665
  const filePath = (0, import_node_path3.join)(this.outputDir, fileResult.file);
1556
1666
  try {
1557
1667
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
1558
- const stripped = this.stripConflictMarkers(content);
1668
+ const stripped = stripConflictMarkers(content);
1559
1669
  (0, import_node_fs2.writeFileSync)(filePath, stripped);
1560
1670
  } catch {
1561
1671
  }
@@ -1567,7 +1677,7 @@ var ReplayService = class {
1567
1677
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
1568
1678
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
1569
1679
  }
1570
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts) {
1680
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
1571
1681
  const conflictResults = results.filter((r) => r.status === "conflict");
1572
1682
  const conflictDetails = conflictResults.map((r) => {
1573
1683
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -1592,6 +1702,7 @@ var ReplayService = class {
1592
1702
  patchesRepointed: rebaseCounts && rebaseCounts.repointed > 0 ? rebaseCounts.repointed : void 0,
1593
1703
  patchesContentRebased: rebaseCounts && rebaseCounts.contentRebased > 0 ? rebaseCounts.contentRebased : void 0,
1594
1704
  patchesKeptAsUserOwned: rebaseCounts && rebaseCounts.keptAsUserOwned > 0 ? rebaseCounts.keptAsUserOwned : void 0,
1705
+ patchesReverted: patchesReverted && patchesReverted > 0 ? patchesReverted : void 0,
1595
1706
  patchesConflictResolved: preRebaseCounts && preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed > 0 ? preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed : void 0,
1596
1707
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
1597
1708
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
@@ -1638,7 +1749,7 @@ var FernignoreMigrator = class {
1638
1749
  async analyzeMigration() {
1639
1750
  const patterns = this.readFernignorePatterns();
1640
1751
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
1641
- const patches = await detector.detectNewPatches();
1752
+ const { patches } = await detector.detectNewPatches();
1642
1753
  const trackedByBoth = [];
1643
1754
  const fernignoreOnly = [];
1644
1755
  const commitsOnly = [];
@@ -1763,7 +1874,7 @@ var FernignoreMigrator = class {
1763
1874
  async migrate() {
1764
1875
  const analysis = await this.analyzeMigration();
1765
1876
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
1766
- const patches = await detector.detectNewPatches();
1877
+ const { patches } = await detector.detectNewPatches();
1767
1878
  const warnings = [];
1768
1879
  let patchesCreated = 0;
1769
1880
  for (const patch of patches) {
@@ -2180,6 +2291,9 @@ function status(outputDir) {
2180
2291
  forget,
2181
2292
  isGenerationCommit,
2182
2293
  isReplayCommit,
2294
+ isRevertCommit,
2295
+ parseRevertedMessage,
2296
+ parseRevertedSha,
2183
2297
  reset,
2184
2298
  resolve,
2185
2299
  status,