@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/cli.cjs CHANGED
@@ -5335,6 +5335,9 @@ var init_GitClient = __esm({
5335
5335
  return false;
5336
5336
  }
5337
5337
  }
5338
+ async getCommitBody(commitSha) {
5339
+ return this.exec(["log", "-1", "--format=%B", commitSha]);
5340
+ }
5338
5341
  getRepoPath() {
5339
5342
  return this.repoPath;
5340
5343
  }
@@ -13009,6 +13012,17 @@ function isGenerationCommit(commit) {
13009
13012
  function isReplayCommit(commit) {
13010
13013
  return commit.message.startsWith("[fern-replay]");
13011
13014
  }
13015
+ function isRevertCommit(message) {
13016
+ return message.startsWith('Revert "');
13017
+ }
13018
+ function parseRevertedSha(fullBody) {
13019
+ const match2 = fullBody.match(/This reverts commit ([0-9a-f]{40})\./);
13020
+ return match2?.[1];
13021
+ }
13022
+ function parseRevertedMessage(subject) {
13023
+ const match2 = subject.match(/^Revert "(.+)"$/);
13024
+ return match2?.[1];
13025
+ }
13012
13026
 
13013
13027
  // src/ReplayDetector.ts
13014
13028
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
@@ -13026,14 +13040,14 @@ var ReplayDetector = class {
13026
13040
  const lock = this.lockManager.read();
13027
13041
  const lastGen = this.getLastGeneration(lock);
13028
13042
  if (!lastGen) {
13029
- return [];
13043
+ return { patches: [], revertedPatchIds: [] };
13030
13044
  }
13031
13045
  const exists2 = await this.git.commitExists(lastGen.commit_sha);
13032
13046
  if (!exists2) {
13033
13047
  this.warnings.push(
13034
13048
  `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Skipping new patch detection. Existing lockfile patches will still be applied.`
13035
13049
  );
13036
- return [];
13050
+ return { patches: [], revertedPatchIds: [] };
13037
13051
  }
13038
13052
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13039
13053
  if (!isAncestor) {
@@ -13047,7 +13061,7 @@ var ReplayDetector = class {
13047
13061
  this.sdkOutputDir
13048
13062
  ]);
13049
13063
  if (!log.trim()) {
13050
- return [];
13064
+ return { patches: [], revertedPatchIds: [] };
13051
13065
  }
13052
13066
  const commits = this.parseGitLog(log);
13053
13067
  const newPatches = [];
@@ -13083,7 +13097,60 @@ var ReplayDetector = class {
13083
13097
  patch_content: patchContent
13084
13098
  });
13085
13099
  }
13086
- return newPatches.reverse();
13100
+ newPatches.reverse();
13101
+ const revertedPatchIdSet = /* @__PURE__ */ new Set();
13102
+ const revertIndicesToRemove = /* @__PURE__ */ new Set();
13103
+ for (let i = 0; i < newPatches.length; i++) {
13104
+ const patch = newPatches[i];
13105
+ if (!isRevertCommit(patch.original_message)) continue;
13106
+ let body = "";
13107
+ try {
13108
+ body = await this.git.getCommitBody(patch.original_commit);
13109
+ } catch {
13110
+ }
13111
+ const revertedSha = parseRevertedSha(body);
13112
+ const revertedMessage = parseRevertedMessage(patch.original_message);
13113
+ let matchedExisting = false;
13114
+ if (revertedSha) {
13115
+ const existing = lock.patches.find((p) => p.original_commit === revertedSha);
13116
+ if (existing) {
13117
+ revertedPatchIdSet.add(existing.id);
13118
+ revertIndicesToRemove.add(i);
13119
+ matchedExisting = true;
13120
+ }
13121
+ }
13122
+ if (!matchedExisting && revertedMessage) {
13123
+ const existing = lock.patches.find((p) => p.original_message === revertedMessage);
13124
+ if (existing) {
13125
+ revertedPatchIdSet.add(existing.id);
13126
+ revertIndicesToRemove.add(i);
13127
+ matchedExisting = true;
13128
+ }
13129
+ }
13130
+ if (matchedExisting) continue;
13131
+ let matchedNew = false;
13132
+ if (revertedSha) {
13133
+ const idx = newPatches.findIndex(
13134
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
13135
+ );
13136
+ if (idx !== -1) {
13137
+ revertIndicesToRemove.add(i);
13138
+ revertIndicesToRemove.add(idx);
13139
+ matchedNew = true;
13140
+ }
13141
+ }
13142
+ if (!matchedNew && revertedMessage) {
13143
+ const idx = newPatches.findIndex(
13144
+ (p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
13145
+ );
13146
+ if (idx !== -1) {
13147
+ revertIndicesToRemove.add(i);
13148
+ revertIndicesToRemove.add(idx);
13149
+ }
13150
+ }
13151
+ }
13152
+ const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
13153
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
13087
13154
  }
13088
13155
  /**
13089
13156
  * Compute content hash for deduplication.
@@ -13094,31 +13161,34 @@ var ReplayDetector = class {
13094
13161
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
13095
13162
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
13096
13163
  }
13097
- /** Detect patches via tree diff for non-linear history. Returns a composite patch. */
13164
+ /**
13165
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
13166
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
13167
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
13168
+ */
13098
13169
  async detectPatchesViaTreeDiff(lastGen) {
13099
13170
  const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
13100
13171
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13101
- if (files.length === 0) return [];
13172
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
13102
13173
  const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
13103
- if (!diff.trim()) return [];
13174
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
13104
13175
  const contentHash = this.computeContentHash(diff);
13105
13176
  const lock = this.lockManager.read();
13106
13177
  if (lock.patches.some((p) => p.content_hash === contentHash)) {
13107
- return [];
13178
+ return { patches: [], revertedPatchIds: [] };
13108
13179
  }
13109
13180
  const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
13110
- return [
13111
- {
13112
- id: `patch-composite-${headSha.slice(0, 8)}`,
13113
- content_hash: contentHash,
13114
- original_commit: headSha,
13115
- original_message: "Customer customizations (composite)",
13116
- original_author: "composite",
13117
- base_generation: lastGen.commit_sha,
13118
- files,
13119
- patch_content: diff
13120
- }
13121
- ];
13181
+ const compositePatch = {
13182
+ id: `patch-composite-${headSha.slice(0, 8)}`,
13183
+ content_hash: contentHash,
13184
+ original_commit: headSha,
13185
+ original_message: "Customer customizations (composite)",
13186
+ original_author: "composite",
13187
+ base_generation: lastGen.commit_sha,
13188
+ files,
13189
+ patch_content: diff
13190
+ };
13191
+ return { patches: [compositePatch], revertedPatchIds: [] };
13122
13192
  }
13123
13193
  parseGitLog(log) {
13124
13194
  return log.trim().split("\n").map((line) => {
@@ -15266,6 +15336,34 @@ CLI Version: ${options.cliVersion}`;
15266
15336
  }
15267
15337
  };
15268
15338
 
15339
+ // src/conflict-utils.ts
15340
+ function stripConflictMarkers(content) {
15341
+ const lines = content.split("\n");
15342
+ const result = [];
15343
+ let inConflict = false;
15344
+ let inOurs = false;
15345
+ for (const line of lines) {
15346
+ if (line.startsWith("<<<<<<< ")) {
15347
+ inConflict = true;
15348
+ inOurs = true;
15349
+ continue;
15350
+ }
15351
+ if (inConflict && line === "=======") {
15352
+ inOurs = false;
15353
+ continue;
15354
+ }
15355
+ if (inConflict && line.startsWith(">>>>>>> ")) {
15356
+ inConflict = false;
15357
+ inOurs = false;
15358
+ continue;
15359
+ }
15360
+ if (!inConflict || inOurs) {
15361
+ result.push(line);
15362
+ }
15363
+ }
15364
+ return result.join("\n");
15365
+ }
15366
+
15269
15367
  // src/ReplayService.ts
15270
15368
  var ReplayService = class {
15271
15369
  git;
@@ -15334,7 +15432,7 @@ var ReplayService = class {
15334
15432
  }
15335
15433
  this.lockManager.save();
15336
15434
  try {
15337
- const redetectedPatches = await this.detector.detectNewPatches();
15435
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
15338
15436
  if (redetectedPatches.length > 0) {
15339
15437
  const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
15340
15438
  const currentPatches = this.lockManager.getPatches();
@@ -15425,7 +15523,7 @@ var ReplayService = class {
15425
15523
  };
15426
15524
  }
15427
15525
  async handleNoPatchesRegeneration(options) {
15428
- const newPatches = await this.detector.detectNewPatches();
15526
+ const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
15429
15527
  const warnings = [...this.detector.warnings];
15430
15528
  if (options?.dryRun) {
15431
15529
  return {
@@ -15434,11 +15532,18 @@ var ReplayService = class {
15434
15532
  patchesApplied: 0,
15435
15533
  patchesWithConflicts: 0,
15436
15534
  patchesSkipped: 0,
15535
+ patchesReverted: revertedPatchIds.length,
15437
15536
  conflicts: [],
15438
15537
  wouldApply: newPatches,
15439
15538
  warnings: warnings.length > 0 ? warnings : void 0
15440
15539
  };
15441
15540
  }
15541
+ for (const id of revertedPatchIds) {
15542
+ try {
15543
+ this.lockManager.removePatch(id);
15544
+ } catch {
15545
+ }
15546
+ }
15442
15547
  const commitOpts = options ? {
15443
15548
  cliVersion: options.cliVersion ?? "unknown",
15444
15549
  generatorVersions: options.generatorVersions ?? {},
@@ -15472,12 +15577,12 @@ var ReplayService = class {
15472
15577
  await this.committer.stageAll();
15473
15578
  }
15474
15579
  }
15475
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts);
15580
+ return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
15476
15581
  }
15477
15582
  async handleNormalRegeneration(options) {
15478
15583
  if (options?.dryRun) {
15479
15584
  const existingPatches2 = this.lockManager.getPatches();
15480
- const newPatches2 = await this.detector.detectNewPatches();
15585
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
15481
15586
  const warnings2 = [...this.detector.warnings];
15482
15587
  const allPatches2 = [...existingPatches2, ...newPatches2];
15483
15588
  return {
@@ -15486,13 +15591,19 @@ var ReplayService = class {
15486
15591
  patchesApplied: 0,
15487
15592
  patchesWithConflicts: 0,
15488
15593
  patchesSkipped: 0,
15594
+ patchesReverted: dryRunReverted.length,
15489
15595
  conflicts: [],
15490
15596
  wouldApply: allPatches2,
15491
15597
  warnings: warnings2.length > 0 ? warnings2 : void 0
15492
15598
  };
15493
15599
  }
15494
15600
  let existingPatches = this.lockManager.getPatches();
15601
+ const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
15495
15602
  const preRebaseCounts = await this.preGenerationRebase(existingPatches);
15603
+ const postRebasePatchIds = new Set(this.lockManager.getPatches().map((p) => p.id));
15604
+ const removedByPreRebase = existingPatches.filter(
15605
+ (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
15606
+ );
15496
15607
  existingPatches = this.lockManager.getPatches();
15497
15608
  const seenHashes = /* @__PURE__ */ new Set();
15498
15609
  for (const p of existingPatches) {
@@ -15503,8 +15614,28 @@ var ReplayService = class {
15503
15614
  }
15504
15615
  }
15505
15616
  existingPatches = this.lockManager.getPatches();
15506
- const newPatches = await this.detector.detectNewPatches();
15617
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
15507
15618
  const warnings = [...this.detector.warnings];
15619
+ if (removedByPreRebase.length > 0) {
15620
+ const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
15621
+ const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
15622
+ newPatches = newPatches.filter((p) => {
15623
+ if (removedOriginalCommits.has(p.original_commit)) return false;
15624
+ if (isRevertCommit(p.original_message)) {
15625
+ const revertedMsg = parseRevertedMessage(p.original_message);
15626
+ if (revertedMsg && removedOriginalMessages.has(revertedMsg)) return false;
15627
+ }
15628
+ return true;
15629
+ });
15630
+ }
15631
+ for (const id of revertedPatchIds) {
15632
+ try {
15633
+ this.lockManager.removePatch(id);
15634
+ } catch {
15635
+ }
15636
+ }
15637
+ const revertedSet = new Set(revertedPatchIds);
15638
+ existingPatches = existingPatches.filter((p) => !revertedSet.has(p.id));
15508
15639
  const allPatches = [...existingPatches, ...newPatches];
15509
15640
  const commitOpts = options ? {
15510
15641
  cliVersion: options.cliVersion ?? "unknown",
@@ -15549,7 +15680,8 @@ var ReplayService = class {
15549
15680
  options,
15550
15681
  warnings,
15551
15682
  rebaseCounts,
15552
- preRebaseCounts
15683
+ preRebaseCounts,
15684
+ revertedPatchIds.length
15553
15685
  );
15554
15686
  }
15555
15687
  /**
@@ -15742,36 +15874,6 @@ var ReplayService = class {
15742
15874
  }
15743
15875
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
15744
15876
  }
15745
- /**
15746
- * Strip conflict markers from file content, keeping the OURS (Generated) side.
15747
- * Preserves clean patches' non-conflicting changes on shared files.
15748
- */
15749
- stripConflictMarkers(content) {
15750
- const lines = content.split("\n");
15751
- const result = [];
15752
- let inConflict = false;
15753
- let inOurs = false;
15754
- for (const line of lines) {
15755
- if (line.startsWith("<<<<<<< ")) {
15756
- inConflict = true;
15757
- inOurs = true;
15758
- continue;
15759
- }
15760
- if (inConflict && line === "=======") {
15761
- inOurs = false;
15762
- continue;
15763
- }
15764
- if (inConflict && line.startsWith(">>>>>>> ")) {
15765
- inConflict = false;
15766
- inOurs = false;
15767
- continue;
15768
- }
15769
- if (!inConflict || inOurs) {
15770
- result.push(line);
15771
- }
15772
- }
15773
- return result.join("\n");
15774
- }
15775
15877
  /**
15776
15878
  * After applyPatches(), strip conflict markers from conflicting files
15777
15879
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -15784,7 +15886,7 @@ var ReplayService = class {
15784
15886
  const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
15785
15887
  try {
15786
15888
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
15787
- const stripped = this.stripConflictMarkers(content);
15889
+ const stripped = stripConflictMarkers(content);
15788
15890
  (0, import_node_fs2.writeFileSync)(filePath, stripped);
15789
15891
  } catch {
15790
15892
  }
@@ -15796,7 +15898,7 @@ var ReplayService = class {
15796
15898
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
15797
15899
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
15798
15900
  }
15799
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts) {
15901
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
15800
15902
  const conflictResults = results.filter((r) => r.status === "conflict");
15801
15903
  const conflictDetails = conflictResults.map((r) => {
15802
15904
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -15821,6 +15923,7 @@ var ReplayService = class {
15821
15923
  patchesRepointed: rebaseCounts && rebaseCounts.repointed > 0 ? rebaseCounts.repointed : void 0,
15822
15924
  patchesContentRebased: rebaseCounts && rebaseCounts.contentRebased > 0 ? rebaseCounts.contentRebased : void 0,
15823
15925
  patchesKeptAsUserOwned: rebaseCounts && rebaseCounts.keptAsUserOwned > 0 ? rebaseCounts.keptAsUserOwned : void 0,
15926
+ patchesReverted: patchesReverted && patchesReverted > 0 ? patchesReverted : void 0,
15824
15927
  patchesConflictResolved: preRebaseCounts && preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed > 0 ? preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed : void 0,
15825
15928
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
15826
15929
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
@@ -15866,7 +15969,7 @@ var FernignoreMigrator = class {
15866
15969
  async analyzeMigration() {
15867
15970
  const patterns = this.readFernignorePatterns();
15868
15971
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
15869
- const patches = await detector.detectNewPatches();
15972
+ const { patches } = await detector.detectNewPatches();
15870
15973
  const trackedByBoth = [];
15871
15974
  const fernignoreOnly = [];
15872
15975
  const commitsOnly = [];
@@ -15991,7 +16094,7 @@ var FernignoreMigrator = class {
15991
16094
  async migrate() {
15992
16095
  const analysis = await this.analyzeMigration();
15993
16096
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
15994
- const patches = await detector.detectNewPatches();
16097
+ const { patches } = await detector.detectNewPatches();
15995
16098
  const warnings = [];
15996
16099
  let patchesCreated = 0;
15997
16100
  for (const patch of patches) {
@@ -16544,11 +16647,14 @@ async function runDetect(dir) {
16544
16647
  lockManager.read();
16545
16648
  const git = new GitClient(dir);
16546
16649
  const detector = new ReplayDetector(git, lockManager, dir);
16547
- const patches = await detector.detectNewPatches();
16650
+ const { patches, revertedPatchIds } = await detector.detectNewPatches();
16548
16651
  if (detector.warnings.length > 0) {
16549
16652
  for (const w of detector.warnings) console.log(`Warning: ${w}`);
16550
16653
  console.log();
16551
16654
  }
16655
+ if (revertedPatchIds.length > 0) {
16656
+ console.log(`Reverted patches: ${revertedPatchIds.length}`);
16657
+ }
16552
16658
  console.log(`Detected ${patches.length} new patch(es) since last generation:
16553
16659
  `);
16554
16660
  if (patches.length === 0) {
@@ -16590,6 +16696,9 @@ function printReport(report) {
16590
16696
  if (report.patchesKeptAsUserOwned) {
16591
16697
  console.log(`Patches kept (user-owned files): ${report.patchesKeptAsUserOwned}`);
16592
16698
  }
16699
+ if (report.patchesReverted) {
16700
+ console.log(`Patches reverted by user: ${report.patchesReverted}`);
16701
+ }
16593
16702
  if (report.patchesPartiallyApplied) {
16594
16703
  console.log(`Patches partially applied: ${report.patchesPartiallyApplied}`);
16595
16704
  }