@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/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 /^Revert ".+"$/.test(message);
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,63 @@ 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
+ if (!matchedExisting && !matchedNew) {
13152
+ revertIndicesToRemove.add(i);
13153
+ }
13154
+ }
13155
+ const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
13156
+ return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
13087
13157
  }
13088
13158
  /**
13089
13159
  * Compute content hash for deduplication.
@@ -13094,31 +13164,34 @@ var ReplayDetector = class {
13094
13164
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
13095
13165
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
13096
13166
  }
13097
- /** Detect patches via tree diff for non-linear history. Returns a composite patch. */
13167
+ /**
13168
+ * Detect patches via tree diff for non-linear history. Returns a composite patch.
13169
+ * Revert reconciliation is skipped here because tree-diff produces a single composite
13170
+ * patch from the aggregate diff — individual revert commits are not distinguishable.
13171
+ */
13098
13172
  async detectPatchesViaTreeDiff(lastGen) {
13099
13173
  const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
13100
13174
  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 [];
13175
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
13102
13176
  const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
13103
- if (!diff.trim()) return [];
13177
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
13104
13178
  const contentHash = this.computeContentHash(diff);
13105
13179
  const lock = this.lockManager.read();
13106
13180
  if (lock.patches.some((p) => p.content_hash === contentHash)) {
13107
- return [];
13181
+ return { patches: [], revertedPatchIds: [] };
13108
13182
  }
13109
13183
  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
- ];
13184
+ const compositePatch = {
13185
+ id: `patch-composite-${headSha.slice(0, 8)}`,
13186
+ content_hash: contentHash,
13187
+ original_commit: headSha,
13188
+ original_message: "Customer customizations (composite)",
13189
+ original_author: "composite",
13190
+ base_generation: lastGen.commit_sha,
13191
+ files,
13192
+ patch_content: diff
13193
+ };
13194
+ return { patches: [compositePatch], revertedPatchIds: [] };
13122
13195
  }
13123
13196
  parseGitLog(log) {
13124
13197
  return log.trim().split("\n").map((line) => {
@@ -15266,6 +15339,34 @@ CLI Version: ${options.cliVersion}`;
15266
15339
  }
15267
15340
  };
15268
15341
 
15342
+ // src/conflict-utils.ts
15343
+ function stripConflictMarkers(content) {
15344
+ const lines = content.split("\n");
15345
+ const result = [];
15346
+ let inConflict = false;
15347
+ let inOurs = false;
15348
+ for (const line of lines) {
15349
+ if (line.startsWith("<<<<<<< ")) {
15350
+ inConflict = true;
15351
+ inOurs = true;
15352
+ continue;
15353
+ }
15354
+ if (inConflict && line === "=======") {
15355
+ inOurs = false;
15356
+ continue;
15357
+ }
15358
+ if (inConflict && line.startsWith(">>>>>>> ")) {
15359
+ inConflict = false;
15360
+ inOurs = false;
15361
+ continue;
15362
+ }
15363
+ if (!inConflict || inOurs) {
15364
+ result.push(line);
15365
+ }
15366
+ }
15367
+ return result.join("\n");
15368
+ }
15369
+
15269
15370
  // src/ReplayService.ts
15270
15371
  var ReplayService = class {
15271
15372
  git;
@@ -15334,7 +15435,7 @@ var ReplayService = class {
15334
15435
  }
15335
15436
  this.lockManager.save();
15336
15437
  try {
15337
- const redetectedPatches = await this.detector.detectNewPatches();
15438
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
15338
15439
  if (redetectedPatches.length > 0) {
15339
15440
  const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
15340
15441
  const currentPatches = this.lockManager.getPatches();
@@ -15425,7 +15526,7 @@ var ReplayService = class {
15425
15526
  };
15426
15527
  }
15427
15528
  async handleNoPatchesRegeneration(options) {
15428
- const newPatches = await this.detector.detectNewPatches();
15529
+ const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
15429
15530
  const warnings = [...this.detector.warnings];
15430
15531
  if (options?.dryRun) {
15431
15532
  return {
@@ -15434,11 +15535,18 @@ var ReplayService = class {
15434
15535
  patchesApplied: 0,
15435
15536
  patchesWithConflicts: 0,
15436
15537
  patchesSkipped: 0,
15538
+ patchesReverted: revertedPatchIds.length,
15437
15539
  conflicts: [],
15438
15540
  wouldApply: newPatches,
15439
15541
  warnings: warnings.length > 0 ? warnings : void 0
15440
15542
  };
15441
15543
  }
15544
+ for (const id of revertedPatchIds) {
15545
+ try {
15546
+ this.lockManager.removePatch(id);
15547
+ } catch {
15548
+ }
15549
+ }
15442
15550
  const commitOpts = options ? {
15443
15551
  cliVersion: options.cliVersion ?? "unknown",
15444
15552
  generatorVersions: options.generatorVersions ?? {},
@@ -15472,12 +15580,12 @@ var ReplayService = class {
15472
15580
  await this.committer.stageAll();
15473
15581
  }
15474
15582
  }
15475
- return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts);
15583
+ return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
15476
15584
  }
15477
15585
  async handleNormalRegeneration(options) {
15478
15586
  if (options?.dryRun) {
15479
15587
  const existingPatches2 = this.lockManager.getPatches();
15480
- const newPatches2 = await this.detector.detectNewPatches();
15588
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
15481
15589
  const warnings2 = [...this.detector.warnings];
15482
15590
  const allPatches2 = [...existingPatches2, ...newPatches2];
15483
15591
  return {
@@ -15486,13 +15594,19 @@ var ReplayService = class {
15486
15594
  patchesApplied: 0,
15487
15595
  patchesWithConflicts: 0,
15488
15596
  patchesSkipped: 0,
15597
+ patchesReverted: dryRunReverted.length,
15489
15598
  conflicts: [],
15490
15599
  wouldApply: allPatches2,
15491
15600
  warnings: warnings2.length > 0 ? warnings2 : void 0
15492
15601
  };
15493
15602
  }
15494
15603
  let existingPatches = this.lockManager.getPatches();
15604
+ const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
15495
15605
  const preRebaseCounts = await this.preGenerationRebase(existingPatches);
15606
+ const postRebasePatchIds = new Set(this.lockManager.getPatches().map((p) => p.id));
15607
+ const removedByPreRebase = existingPatches.filter(
15608
+ (p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
15609
+ );
15496
15610
  existingPatches = this.lockManager.getPatches();
15497
15611
  const seenHashes = /* @__PURE__ */ new Set();
15498
15612
  for (const p of existingPatches) {
@@ -15503,8 +15617,28 @@ var ReplayService = class {
15503
15617
  }
15504
15618
  }
15505
15619
  existingPatches = this.lockManager.getPatches();
15506
- const newPatches = await this.detector.detectNewPatches();
15620
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
15507
15621
  const warnings = [...this.detector.warnings];
15622
+ if (removedByPreRebase.length > 0) {
15623
+ const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
15624
+ const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
15625
+ newPatches = newPatches.filter((p) => {
15626
+ if (removedOriginalCommits.has(p.original_commit)) return false;
15627
+ if (isRevertCommit(p.original_message)) {
15628
+ const revertedMsg = parseRevertedMessage(p.original_message);
15629
+ if (revertedMsg && removedOriginalMessages.has(revertedMsg)) return false;
15630
+ }
15631
+ return true;
15632
+ });
15633
+ }
15634
+ for (const id of revertedPatchIds) {
15635
+ try {
15636
+ this.lockManager.removePatch(id);
15637
+ } catch {
15638
+ }
15639
+ }
15640
+ const revertedSet = new Set(revertedPatchIds);
15641
+ existingPatches = existingPatches.filter((p) => !revertedSet.has(p.id));
15508
15642
  const allPatches = [...existingPatches, ...newPatches];
15509
15643
  const commitOpts = options ? {
15510
15644
  cliVersion: options.cliVersion ?? "unknown",
@@ -15549,7 +15683,8 @@ var ReplayService = class {
15549
15683
  options,
15550
15684
  warnings,
15551
15685
  rebaseCounts,
15552
- preRebaseCounts
15686
+ preRebaseCounts,
15687
+ revertedPatchIds.length
15553
15688
  );
15554
15689
  }
15555
15690
  /**
@@ -15742,36 +15877,6 @@ var ReplayService = class {
15742
15877
  }
15743
15878
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
15744
15879
  }
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
15880
  /**
15776
15881
  * After applyPatches(), strip conflict markers from conflicting files
15777
15882
  * so only clean content is committed. Keeps the Generated (OURS) side.
@@ -15784,7 +15889,7 @@ var ReplayService = class {
15784
15889
  const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
15785
15890
  try {
15786
15891
  const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
15787
- const stripped = this.stripConflictMarkers(content);
15892
+ const stripped = stripConflictMarkers(content);
15788
15893
  (0, import_node_fs2.writeFileSync)(filePath, stripped);
15789
15894
  } catch {
15790
15895
  }
@@ -15796,7 +15901,7 @@ var ReplayService = class {
15796
15901
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
15797
15902
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
15798
15903
  }
15799
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts) {
15904
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
15800
15905
  const conflictResults = results.filter((r) => r.status === "conflict");
15801
15906
  const conflictDetails = conflictResults.map((r) => {
15802
15907
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -15821,6 +15926,7 @@ var ReplayService = class {
15821
15926
  patchesRepointed: rebaseCounts && rebaseCounts.repointed > 0 ? rebaseCounts.repointed : void 0,
15822
15927
  patchesContentRebased: rebaseCounts && rebaseCounts.contentRebased > 0 ? rebaseCounts.contentRebased : void 0,
15823
15928
  patchesKeptAsUserOwned: rebaseCounts && rebaseCounts.keptAsUserOwned > 0 ? rebaseCounts.keptAsUserOwned : void 0,
15929
+ patchesReverted: patchesReverted && patchesReverted > 0 ? patchesReverted : void 0,
15824
15930
  patchesConflictResolved: preRebaseCounts && preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed > 0 ? preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed : void 0,
15825
15931
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
15826
15932
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
@@ -15866,7 +15972,7 @@ var FernignoreMigrator = class {
15866
15972
  async analyzeMigration() {
15867
15973
  const patterns = this.readFernignorePatterns();
15868
15974
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
15869
- const patches = await detector.detectNewPatches();
15975
+ const { patches } = await detector.detectNewPatches();
15870
15976
  const trackedByBoth = [];
15871
15977
  const fernignoreOnly = [];
15872
15978
  const commitsOnly = [];
@@ -15991,7 +16097,7 @@ var FernignoreMigrator = class {
15991
16097
  async migrate() {
15992
16098
  const analysis = await this.analyzeMigration();
15993
16099
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
15994
- const patches = await detector.detectNewPatches();
16100
+ const { patches } = await detector.detectNewPatches();
15995
16101
  const warnings = [];
15996
16102
  let patchesCreated = 0;
15997
16103
  for (const patch of patches) {
@@ -16544,11 +16650,14 @@ async function runDetect(dir) {
16544
16650
  lockManager.read();
16545
16651
  const git = new GitClient(dir);
16546
16652
  const detector = new ReplayDetector(git, lockManager, dir);
16547
- const patches = await detector.detectNewPatches();
16653
+ const { patches, revertedPatchIds } = await detector.detectNewPatches();
16548
16654
  if (detector.warnings.length > 0) {
16549
16655
  for (const w of detector.warnings) console.log(`Warning: ${w}`);
16550
16656
  console.log();
16551
16657
  }
16658
+ if (revertedPatchIds.length > 0) {
16659
+ console.log(`Reverted patches: ${revertedPatchIds.length}`);
16660
+ }
16552
16661
  console.log(`Detected ${patches.length} new patch(es) since last generation:
16553
16662
  `);
16554
16663
  if (patches.length === 0) {
@@ -16590,6 +16699,9 @@ function printReport(report) {
16590
16699
  if (report.patchesKeptAsUserOwned) {
16591
16700
  console.log(`Patches kept (user-owned files): ${report.patchesKeptAsUserOwned}`);
16592
16701
  }
16702
+ if (report.patchesReverted) {
16703
+ console.log(`Patches reverted by user: ${report.patchesReverted}`);
16704
+ }
16593
16705
  if (report.patchesPartiallyApplied) {
16594
16706
  console.log(`Patches partially applied: ${report.patchesPartiallyApplied}`);
16595
16707
  }