@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/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
  }
@@ -12935,6 +12938,26 @@ var LockfileManager = class {
12935
12938
  this.ensureLoaded();
12936
12939
  this.lock.patches = [];
12937
12940
  }
12941
+ getUnresolvedPatches() {
12942
+ this.ensureLoaded();
12943
+ return this.lock.patches.filter((p) => p.status === "unresolved");
12944
+ }
12945
+ getResolvingPatches() {
12946
+ this.ensureLoaded();
12947
+ return this.lock.patches.filter((p) => p.status === "resolving");
12948
+ }
12949
+ markPatchUnresolved(patchId) {
12950
+ this.updatePatch(patchId, { status: "unresolved" });
12951
+ }
12952
+ markPatchResolved(patchId, updates) {
12953
+ this.ensureLoaded();
12954
+ const patch = this.lock.patches.find((p) => p.id === patchId);
12955
+ if (!patch) {
12956
+ throw new Error(`Patch not found: ${patchId}`);
12957
+ }
12958
+ delete patch.status;
12959
+ Object.assign(patch, updates);
12960
+ }
12938
12961
  getPatches() {
12939
12962
  this.ensureLoaded();
12940
12963
  return this.lock.patches;
@@ -12989,6 +13012,17 @@ function isGenerationCommit(commit) {
12989
13012
  function isReplayCommit(commit) {
12990
13013
  return commit.message.startsWith("[fern-replay]");
12991
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
+ }
12992
13026
 
12993
13027
  // src/ReplayDetector.ts
12994
13028
  var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
@@ -13006,14 +13040,14 @@ var ReplayDetector = class {
13006
13040
  const lock = this.lockManager.read();
13007
13041
  const lastGen = this.getLastGeneration(lock);
13008
13042
  if (!lastGen) {
13009
- return [];
13043
+ return { patches: [], revertedPatchIds: [] };
13010
13044
  }
13011
13045
  const exists2 = await this.git.commitExists(lastGen.commit_sha);
13012
13046
  if (!exists2) {
13013
13047
  this.warnings.push(
13014
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.`
13015
13049
  );
13016
- return [];
13050
+ return { patches: [], revertedPatchIds: [] };
13017
13051
  }
13018
13052
  const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13019
13053
  if (!isAncestor) {
@@ -13027,7 +13061,7 @@ var ReplayDetector = class {
13027
13061
  this.sdkOutputDir
13028
13062
  ]);
13029
13063
  if (!log.trim()) {
13030
- return [];
13064
+ return { patches: [], revertedPatchIds: [] };
13031
13065
  }
13032
13066
  const commits = this.parseGitLog(log);
13033
13067
  const newPatches = [];
@@ -13063,7 +13097,60 @@ var ReplayDetector = class {
13063
13097
  patch_content: patchContent
13064
13098
  });
13065
13099
  }
13066
- 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] };
13067
13154
  }
13068
13155
  /**
13069
13156
  * Compute content hash for deduplication.
@@ -13074,31 +13161,34 @@ var ReplayDetector = class {
13074
13161
  const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
13075
13162
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
13076
13163
  }
13077
- /** 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
+ */
13078
13169
  async detectPatchesViaTreeDiff(lastGen) {
13079
13170
  const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
13080
13171
  const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
13081
- if (files.length === 0) return [];
13172
+ if (files.length === 0) return { patches: [], revertedPatchIds: [] };
13082
13173
  const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
13083
- if (!diff.trim()) return [];
13174
+ if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
13084
13175
  const contentHash = this.computeContentHash(diff);
13085
13176
  const lock = this.lockManager.read();
13086
13177
  if (lock.patches.some((p) => p.content_hash === contentHash)) {
13087
- return [];
13178
+ return { patches: [], revertedPatchIds: [] };
13088
13179
  }
13089
13180
  const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
13090
- return [
13091
- {
13092
- id: `patch-composite-${headSha.slice(0, 8)}`,
13093
- content_hash: contentHash,
13094
- original_commit: headSha,
13095
- original_message: "Customer customizations (composite)",
13096
- original_author: "composite",
13097
- base_generation: lastGen.commit_sha,
13098
- files,
13099
- patch_content: diff
13100
- }
13101
- ];
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: [] };
13102
13192
  }
13103
13193
  parseGitLog(log) {
13104
13194
  return log.trim().split("\n").map((line) => {
@@ -15206,12 +15296,12 @@ CLI Version: ${options.cliVersion}`;
15206
15296
  await this.git.exec(["commit", "-m", fullMessage]);
15207
15297
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
15208
15298
  }
15209
- async commitReplay(_patchCount, patches) {
15299
+ async commitReplay(_patchCount, patches, message) {
15210
15300
  await this.stageAll();
15211
15301
  if (!await this.hasStagedChanges()) {
15212
15302
  return (await this.git.exec(["rev-parse", "HEAD"])).trim();
15213
15303
  }
15214
- let fullMessage = `[fern-replay] Applied customizations`;
15304
+ let fullMessage = message ?? `[fern-replay] Applied customizations`;
15215
15305
  if (patches && patches.length > 0) {
15216
15306
  fullMessage += "\n\nPatches replayed:";
15217
15307
  for (const patch of patches) {
@@ -15246,6 +15336,34 @@ CLI Version: ${options.cliVersion}`;
15246
15336
  }
15247
15337
  };
15248
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
+
15249
15367
  // src/ReplayService.ts
15250
15368
  var ReplayService = class {
15251
15369
  git;
@@ -15302,13 +15420,27 @@ var ReplayService = class {
15302
15420
  this.lockManager.initializeInMemory(record);
15303
15421
  } else {
15304
15422
  this.lockManager.read();
15423
+ const unresolvedPatches = [
15424
+ ...this.lockManager.getUnresolvedPatches(),
15425
+ ...this.lockManager.getResolvingPatches()
15426
+ ];
15305
15427
  this.lockManager.addGeneration(record);
15306
15428
  this.lockManager.clearPatches();
15429
+ for (const patch of unresolvedPatches) {
15430
+ this.lockManager.addPatch(patch);
15431
+ }
15307
15432
  }
15308
15433
  this.lockManager.save();
15309
15434
  try {
15310
- const redetectedPatches = await this.detector.detectNewPatches();
15435
+ const { patches: redetectedPatches } = await this.detector.detectNewPatches();
15311
15436
  if (redetectedPatches.length > 0) {
15437
+ const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
15438
+ const currentPatches = this.lockManager.getPatches();
15439
+ for (const patch of currentPatches) {
15440
+ if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
15441
+ this.lockManager.removePatch(patch.id);
15442
+ }
15443
+ }
15312
15444
  for (const patch of redetectedPatches) {
15313
15445
  this.lockManager.addPatch(patch);
15314
15446
  }
@@ -15391,7 +15523,7 @@ var ReplayService = class {
15391
15523
  };
15392
15524
  }
15393
15525
  async handleNoPatchesRegeneration(options) {
15394
- const newPatches = await this.detector.detectNewPatches();
15526
+ const { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
15395
15527
  const warnings = [...this.detector.warnings];
15396
15528
  if (options?.dryRun) {
15397
15529
  return {
@@ -15400,11 +15532,18 @@ var ReplayService = class {
15400
15532
  patchesApplied: 0,
15401
15533
  patchesWithConflicts: 0,
15402
15534
  patchesSkipped: 0,
15535
+ patchesReverted: revertedPatchIds.length,
15403
15536
  conflicts: [],
15404
15537
  wouldApply: newPatches,
15405
15538
  warnings: warnings.length > 0 ? warnings : void 0
15406
15539
  };
15407
15540
  }
15541
+ for (const id of revertedPatchIds) {
15542
+ try {
15543
+ this.lockManager.removePatch(id);
15544
+ } catch {
15545
+ }
15546
+ }
15408
15547
  const commitOpts = options ? {
15409
15548
  cliVersion: options.cliVersion ?? "unknown",
15410
15549
  generatorVersions: options.generatorVersions ?? {},
@@ -15416,6 +15555,12 @@ var ReplayService = class {
15416
15555
  let results = [];
15417
15556
  if (newPatches.length > 0) {
15418
15557
  results = await this.applicator.applyPatches(newPatches);
15558
+ this.revertConflictingFiles(results);
15559
+ for (const result of results) {
15560
+ if (result.status === "conflict") {
15561
+ result.patch.status = "unresolved";
15562
+ }
15563
+ }
15419
15564
  }
15420
15565
  const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
15421
15566
  for (const patch of newPatches) {
@@ -15426,20 +15571,18 @@ var ReplayService = class {
15426
15571
  this.lockManager.save();
15427
15572
  if (newPatches.length > 0) {
15428
15573
  if (!options?.stageOnly) {
15429
- const appliedCount = results.filter((r) => r.status === "applied" || r.status === "conflict").length;
15430
- if (appliedCount > 0) {
15431
- await this.committer.commitReplay(appliedCount, newPatches);
15432
- }
15574
+ const appliedCount = results.filter((r) => r.status === "applied").length;
15575
+ await this.committer.commitReplay(appliedCount, newPatches);
15433
15576
  } else {
15434
15577
  await this.committer.stageAll();
15435
15578
  }
15436
15579
  }
15437
- 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);
15438
15581
  }
15439
15582
  async handleNormalRegeneration(options) {
15440
15583
  if (options?.dryRun) {
15441
15584
  const existingPatches2 = this.lockManager.getPatches();
15442
- const newPatches2 = await this.detector.detectNewPatches();
15585
+ const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
15443
15586
  const warnings2 = [...this.detector.warnings];
15444
15587
  const allPatches2 = [...existingPatches2, ...newPatches2];
15445
15588
  return {
@@ -15448,13 +15591,19 @@ var ReplayService = class {
15448
15591
  patchesApplied: 0,
15449
15592
  patchesWithConflicts: 0,
15450
15593
  patchesSkipped: 0,
15594
+ patchesReverted: dryRunReverted.length,
15451
15595
  conflicts: [],
15452
15596
  wouldApply: allPatches2,
15453
15597
  warnings: warnings2.length > 0 ? warnings2 : void 0
15454
15598
  };
15455
15599
  }
15456
15600
  let existingPatches = this.lockManager.getPatches();
15601
+ const preRebasePatchIds = new Set(existingPatches.map((p) => p.id));
15457
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
+ );
15458
15607
  existingPatches = this.lockManager.getPatches();
15459
15608
  const seenHashes = /* @__PURE__ */ new Set();
15460
15609
  for (const p of existingPatches) {
@@ -15465,8 +15614,28 @@ var ReplayService = class {
15465
15614
  }
15466
15615
  }
15467
15616
  existingPatches = this.lockManager.getPatches();
15468
- const newPatches = await this.detector.detectNewPatches();
15617
+ let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
15469
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));
15470
15639
  const allPatches = [...existingPatches, ...newPatches];
15471
15640
  const commitOpts = options ? {
15472
15641
  cliVersion: options.cliVersion ?? "unknown",
@@ -15477,20 +15646,32 @@ var ReplayService = class {
15477
15646
  const genRecord = await this.committer.createGenerationRecord(commitOpts);
15478
15647
  this.lockManager.addGeneration(genRecord);
15479
15648
  const results = await this.applicator.applyPatches(allPatches);
15649
+ this.revertConflictingFiles(results);
15650
+ for (const result of results) {
15651
+ if (result.status === "conflict") {
15652
+ result.patch.status = "unresolved";
15653
+ }
15654
+ }
15480
15655
  const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
15481
15656
  for (const patch of newPatches) {
15482
15657
  if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
15483
15658
  this.lockManager.addPatch(patch);
15484
15659
  }
15485
15660
  }
15661
+ for (const result of results) {
15662
+ if (result.status === "conflict") {
15663
+ try {
15664
+ this.lockManager.markPatchUnresolved(result.patch.id);
15665
+ } catch {
15666
+ }
15667
+ }
15668
+ }
15486
15669
  this.lockManager.save();
15487
15670
  if (options?.stageOnly) {
15488
15671
  await this.committer.stageAll();
15489
15672
  } else {
15490
- const appliedCount = results.filter((r) => r.status === "applied" || r.status === "conflict").length;
15491
- if (appliedCount > 0) {
15492
- await this.committer.commitReplay(appliedCount, allPatches);
15493
- }
15673
+ const appliedCount = results.filter((r) => r.status === "applied").length;
15674
+ await this.committer.commitReplay(appliedCount, allPatches);
15494
15675
  }
15495
15676
  return this.buildReport(
15496
15677
  "normal-regeneration",
@@ -15499,7 +15680,8 @@ var ReplayService = class {
15499
15680
  options,
15500
15681
  warnings,
15501
15682
  rebaseCounts,
15502
- preRebaseCounts
15683
+ preRebaseCounts,
15684
+ revertedPatchIds.length
15503
15685
  );
15504
15686
  }
15505
15687
  /**
@@ -15638,6 +15820,10 @@ var ReplayService = class {
15638
15820
  let conflictAbsorbed = 0;
15639
15821
  let contentRefreshed = 0;
15640
15822
  for (const patch of patches) {
15823
+ if (patch.status != null) {
15824
+ delete patch.status;
15825
+ continue;
15826
+ }
15641
15827
  if (patch.base_generation === currentGen) {
15642
15828
  try {
15643
15829
  const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
@@ -15688,12 +15874,31 @@ var ReplayService = class {
15688
15874
  }
15689
15875
  return { conflictResolved, conflictAbsorbed, contentRefreshed };
15690
15876
  }
15877
+ /**
15878
+ * After applyPatches(), strip conflict markers from conflicting files
15879
+ * so only clean content is committed. Keeps the Generated (OURS) side.
15880
+ */
15881
+ revertConflictingFiles(results) {
15882
+ for (const result of results) {
15883
+ if (result.status !== "conflict" || !result.fileResults) continue;
15884
+ for (const fileResult of result.fileResults) {
15885
+ if (fileResult.status !== "conflict") continue;
15886
+ const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
15887
+ try {
15888
+ const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
15889
+ const stripped = stripConflictMarkers(content);
15890
+ (0, import_node_fs2.writeFileSync)(filePath, stripped);
15891
+ } catch {
15892
+ }
15893
+ }
15894
+ }
15895
+ }
15691
15896
  readFernignorePatterns() {
15692
15897
  const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
15693
15898
  if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
15694
15899
  return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
15695
15900
  }
15696
- buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts) {
15901
+ buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts, patchesReverted) {
15697
15902
  const conflictResults = results.filter((r) => r.status === "conflict");
15698
15903
  const conflictDetails = conflictResults.map((r) => {
15699
15904
  const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
@@ -15718,10 +15923,17 @@ var ReplayService = class {
15718
15923
  patchesRepointed: rebaseCounts && rebaseCounts.repointed > 0 ? rebaseCounts.repointed : void 0,
15719
15924
  patchesContentRebased: rebaseCounts && rebaseCounts.contentRebased > 0 ? rebaseCounts.contentRebased : void 0,
15720
15925
  patchesKeptAsUserOwned: rebaseCounts && rebaseCounts.keptAsUserOwned > 0 ? rebaseCounts.keptAsUserOwned : void 0,
15926
+ patchesReverted: patchesReverted && patchesReverted > 0 ? patchesReverted : void 0,
15721
15927
  patchesConflictResolved: preRebaseCounts && preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed > 0 ? preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed : void 0,
15722
15928
  patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
15723
15929
  conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
15724
15930
  conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
15931
+ unresolvedPatches: conflictResults.length > 0 ? conflictResults.map((r) => ({
15932
+ patchId: r.patch.id,
15933
+ patchMessage: r.patch.original_message,
15934
+ files: r.patch.files,
15935
+ conflictDetails: r.fileResults?.filter((f) => f.status === "conflict") ?? []
15936
+ })) : void 0,
15725
15937
  wouldApply: options?.dryRun ? patches : void 0,
15726
15938
  warnings: warnings && warnings.length > 0 ? warnings : void 0
15727
15939
  };
@@ -15757,7 +15969,7 @@ var FernignoreMigrator = class {
15757
15969
  async analyzeMigration() {
15758
15970
  const patterns = this.readFernignorePatterns();
15759
15971
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
15760
- const patches = await detector.detectNewPatches();
15972
+ const { patches } = await detector.detectNewPatches();
15761
15973
  const trackedByBoth = [];
15762
15974
  const fernignoreOnly = [];
15763
15975
  const commitsOnly = [];
@@ -15882,7 +16094,7 @@ var FernignoreMigrator = class {
15882
16094
  async migrate() {
15883
16095
  const analysis = await this.analyzeMigration();
15884
16096
  const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
15885
- const patches = await detector.detectNewPatches();
16097
+ const { patches } = await detector.detectNewPatches();
15886
16098
  const warnings = [];
15887
16099
  let patchesCreated = 0;
15888
16100
  for (const patch of patches) {
@@ -16181,17 +16393,82 @@ async function resolve(outputDir, options) {
16181
16393
  return { success: false, reason: "no-patches" };
16182
16394
  }
16183
16395
  const git = new GitClient(outputDir);
16396
+ const unresolvedPatches = lockManager.getUnresolvedPatches();
16397
+ const resolvingPatches = lockManager.getResolvingPatches();
16398
+ if (unresolvedPatches.length > 0) {
16399
+ const applicator = new ReplayApplicator(git, lockManager, outputDir);
16400
+ await applicator.applyPatches(unresolvedPatches);
16401
+ const markerFiles = await findConflictMarkerFiles(git);
16402
+ if (markerFiles.length > 0) {
16403
+ for (const patch of unresolvedPatches) {
16404
+ lockManager.updatePatch(patch.id, { status: "resolving" });
16405
+ }
16406
+ lockManager.save();
16407
+ return {
16408
+ success: false,
16409
+ reason: "conflicts-applied",
16410
+ unresolvedFiles: markerFiles,
16411
+ phase: "applied",
16412
+ patchesApplied: unresolvedPatches.length
16413
+ };
16414
+ }
16415
+ }
16184
16416
  if (options?.checkMarkers !== false) {
16185
- const markerFiles = await git.exec(["grep", "-l", "<<<<<<<", "--", "."]).catch(() => "");
16186
- if (markerFiles.trim()) {
16187
- const files = markerFiles.trim().split("\n").filter(Boolean);
16188
- return { success: false, reason: "unresolved-conflicts", unresolvedFiles: files };
16417
+ const currentMarkerFiles = await findConflictMarkerFiles(git);
16418
+ if (currentMarkerFiles.length > 0) {
16419
+ return { success: false, reason: "unresolved-conflicts", unresolvedFiles: currentMarkerFiles };
16420
+ }
16421
+ }
16422
+ const patchesToCommit = [...resolvingPatches, ...unresolvedPatches];
16423
+ if (patchesToCommit.length > 0) {
16424
+ const currentGen = lock.current_generation;
16425
+ const detector = new ReplayDetector(git, lockManager, outputDir);
16426
+ let patchesResolved = 0;
16427
+ for (const patch of patchesToCommit) {
16428
+ const diff = await git.exec(["diff", currentGen, "--", ...patch.files]).catch(() => null);
16429
+ if (!diff || !diff.trim()) {
16430
+ lockManager.removePatch(patch.id);
16431
+ continue;
16432
+ }
16433
+ const newContentHash = detector.computeContentHash(diff);
16434
+ const changedFiles = await getChangedFiles(git, currentGen, patch.files);
16435
+ lockManager.markPatchResolved(patch.id, {
16436
+ patch_content: diff,
16437
+ content_hash: newContentHash,
16438
+ base_generation: currentGen,
16439
+ files: changedFiles
16440
+ });
16441
+ patchesResolved++;
16189
16442
  }
16443
+ lockManager.save();
16444
+ const committer2 = new ReplayCommitter(git, outputDir);
16445
+ await committer2.stageAll();
16446
+ const commitSha2 = await committer2.commitReplay(
16447
+ lock.patches.length,
16448
+ lock.patches,
16449
+ "[fern-replay] Resolved conflicts"
16450
+ );
16451
+ return {
16452
+ success: true,
16453
+ commitSha: commitSha2,
16454
+ phase: "committed",
16455
+ patchesResolved
16456
+ };
16190
16457
  }
16191
16458
  const committer = new ReplayCommitter(git, outputDir);
16192
16459
  await committer.stageAll();
16193
16460
  const commitSha = await committer.commitReplay(lock.patches.length, lock.patches);
16194
- return { success: true, commitSha };
16461
+ return { success: true, commitSha, phase: "committed" };
16462
+ }
16463
+ async function findConflictMarkerFiles(git) {
16464
+ const output = await git.exec(["grep", "-l", "<<<<<<<", "--", "."]).catch(() => "");
16465
+ return output.trim() ? output.trim().split("\n").filter(Boolean) : [];
16466
+ }
16467
+ async function getChangedFiles(git, currentGen, files) {
16468
+ const filesOutput = await git.exec(["diff", "--name-only", currentGen, "--", ...files]).catch(() => null);
16469
+ if (!filesOutput || !filesOutput.trim()) return files;
16470
+ const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
16471
+ return changed.length > 0 ? changed : files;
16195
16472
  }
16196
16473
 
16197
16474
  // src/cli.ts
@@ -16370,11 +16647,14 @@ async function runDetect(dir) {
16370
16647
  lockManager.read();
16371
16648
  const git = new GitClient(dir);
16372
16649
  const detector = new ReplayDetector(git, lockManager, dir);
16373
- const patches = await detector.detectNewPatches();
16650
+ const { patches, revertedPatchIds } = await detector.detectNewPatches();
16374
16651
  if (detector.warnings.length > 0) {
16375
16652
  for (const w of detector.warnings) console.log(`Warning: ${w}`);
16376
16653
  console.log();
16377
16654
  }
16655
+ if (revertedPatchIds.length > 0) {
16656
+ console.log(`Reverted patches: ${revertedPatchIds.length}`);
16657
+ }
16378
16658
  console.log(`Detected ${patches.length} new patch(es) since last generation:
16379
16659
  `);
16380
16660
  if (patches.length === 0) {
@@ -16416,6 +16696,9 @@ function printReport(report) {
16416
16696
  if (report.patchesKeptAsUserOwned) {
16417
16697
  console.log(`Patches kept (user-owned files): ${report.patchesKeptAsUserOwned}`);
16418
16698
  }
16699
+ if (report.patchesReverted) {
16700
+ console.log(`Patches reverted by user: ${report.patchesReverted}`);
16701
+ }
16419
16702
  if (report.patchesPartiallyApplied) {
16420
16703
  console.log(`Patches partially applied: ${report.patchesPartiallyApplied}`);
16421
16704
  }
@@ -16449,14 +16732,23 @@ function printReport(report) {
16449
16732
  }
16450
16733
  }
16451
16734
  }
16452
- console.log("\nResolve conflicts by editing the marked files, then commit.");
16453
- console.log("Or run: fern-replay forget <file-pattern> to remove problematic patches");
16735
+ console.log("\nOr run: fern-replay forget <file-pattern> to remove problematic patches");
16454
16736
  } else if (report.conflicts.length > 0) {
16455
16737
  console.log("\nConflicts:");
16456
16738
  for (const conflict of report.conflicts) {
16457
16739
  console.log(` ${conflict.file}: ${conflict.reason ?? "merge conflict"}`);
16458
16740
  }
16459
16741
  }
16742
+ if (report.unresolvedPatches && report.unresolvedPatches.length > 0) {
16743
+ console.log("\nUnresolved Patches (need local resolution):");
16744
+ for (const patch of report.unresolvedPatches) {
16745
+ console.log(` Patch: ${patch.patchId} - "${patch.patchMessage}"`);
16746
+ for (const file of patch.files) {
16747
+ console.log(` ${file}`);
16748
+ }
16749
+ }
16750
+ console.log("\nRun 'fern-replay resolve <dir>' on this branch to resolve conflicts.");
16751
+ }
16460
16752
  if (report.warnings && report.warnings.length > 0) {
16461
16753
  console.log("\nWarnings:");
16462
16754
  for (const w of report.warnings) console.log(` ${w}`);
@@ -16572,6 +16864,17 @@ async function runResolve(dir, flags) {
16572
16864
  case "no-patches":
16573
16865
  console.error("No patches in lockfile. Nothing to resolve.");
16574
16866
  break;
16867
+ case "conflicts-applied":
16868
+ console.log(`Applied ${result.patchesApplied} unresolved patch(es) to working tree.`);
16869
+ console.log(`
16870
+ ${result.unresolvedFiles.length} file(s) have conflicts:`);
16871
+ for (const f of result.unresolvedFiles) {
16872
+ console.log(` ${f}`);
16873
+ }
16874
+ console.log(`
16875
+ Resolve the conflicts in your editor, then run 'fern-replay resolve ${dir}' again.`);
16876
+ return;
16877
+ // Normal flow, don't exit(1)
16575
16878
  case "unresolved-conflicts":
16576
16879
  console.error("Conflict markers still present in:");
16577
16880
  for (const f of result.unresolvedFiles ?? []) {
@@ -16585,8 +16888,11 @@ async function runResolve(dir, flags) {
16585
16888
  }
16586
16889
  process.exit(1);
16587
16890
  }
16891
+ if (result.patchesResolved) {
16892
+ console.log(`Resolution complete. Updated ${result.patchesResolved} patch(es) in lockfile.`);
16893
+ }
16588
16894
  console.log(`Created [fern-replay] commit: ${result.commitSha?.slice(0, 7)}`);
16589
- console.log("Conflict resolution complete.");
16895
+ console.log("Push to update the PR.");
16590
16896
  }
16591
16897
  async function main() {
16592
16898
  const { command, dir, flags, pattern } = parseArgs(process.argv);