@fern-api/replay 0.13.0 → 0.14.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 +666 -60
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +664 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -3
- package/dist/index.d.ts +72 -3
- package/dist/index.js +663 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.cjs
CHANGED
|
@@ -5368,9 +5368,9 @@ var init_GitClient = __esm({
|
|
|
5368
5368
|
return this.git.raw(args);
|
|
5369
5369
|
}
|
|
5370
5370
|
async execWithInput(args, input) {
|
|
5371
|
-
const { spawn:
|
|
5371
|
+
const { spawn: spawn3 } = await import("child_process");
|
|
5372
5372
|
return new Promise((resolve2, reject) => {
|
|
5373
|
-
const proc =
|
|
5373
|
+
const proc = spawn3("git", args, { cwd: this.repoPath });
|
|
5374
5374
|
let stdout = "";
|
|
5375
5375
|
let stderr = "";
|
|
5376
5376
|
proc.stdout.on("data", (data) => {
|
|
@@ -9886,7 +9886,7 @@ var require_resolve_block_scalar = __commonJS({
|
|
|
9886
9886
|
if (!header)
|
|
9887
9887
|
return { value: "", type: null, comment: "", range: [start, start, start] };
|
|
9888
9888
|
const type = header.mode === ">" ? Scalar.Scalar.BLOCK_FOLDED : Scalar.Scalar.BLOCK_LITERAL;
|
|
9889
|
-
const lines = scalar.source ?
|
|
9889
|
+
const lines = scalar.source ? splitLines2(scalar.source) : [];
|
|
9890
9890
|
let chompStart = lines.length;
|
|
9891
9891
|
for (let i = lines.length - 1; i >= 0; --i) {
|
|
9892
9892
|
const content = lines[i][1];
|
|
@@ -10044,7 +10044,7 @@ var require_resolve_block_scalar = __commonJS({
|
|
|
10044
10044
|
}
|
|
10045
10045
|
return { mode, indent, chomp, comment, length };
|
|
10046
10046
|
}
|
|
10047
|
-
function
|
|
10047
|
+
function splitLines2(source) {
|
|
10048
10048
|
const split = source.split(/\n( *)/);
|
|
10049
10049
|
const first2 = split[0];
|
|
10050
10050
|
const m = first2.match(/^( *)/);
|
|
@@ -13233,7 +13233,7 @@ var init_HybridReconstruction = __esm({
|
|
|
13233
13233
|
});
|
|
13234
13234
|
|
|
13235
13235
|
// src/cli.ts
|
|
13236
|
-
var
|
|
13236
|
+
var import_node_path9 = require("path");
|
|
13237
13237
|
var import_node_fs6 = require("fs");
|
|
13238
13238
|
var import_node_readline = require("readline");
|
|
13239
13239
|
init_GitClient();
|
|
@@ -14060,7 +14060,7 @@ var ReplayDetector = class {
|
|
|
14060
14060
|
|
|
14061
14061
|
// src/ReplayService.ts
|
|
14062
14062
|
var import_node_fs2 = require("fs");
|
|
14063
|
-
var
|
|
14063
|
+
var import_node_path5 = require("path");
|
|
14064
14064
|
|
|
14065
14065
|
// node_modules/minimatch/dist/esm/index.js
|
|
14066
14066
|
var import_brace_expansion = __toESM(require_brace_expansion(), 1);
|
|
@@ -15761,11 +15761,43 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
15761
15761
|
}
|
|
15762
15762
|
}
|
|
15763
15763
|
}
|
|
15764
|
-
|
|
15764
|
+
const result = {
|
|
15765
15765
|
content: outputLines.join("\n"),
|
|
15766
15766
|
hasConflicts: conflicts2.length > 0,
|
|
15767
15767
|
conflicts: conflicts2
|
|
15768
15768
|
};
|
|
15769
|
+
if (conflicts2.length === 0) {
|
|
15770
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
15771
|
+
if (dropped.length > 0) {
|
|
15772
|
+
result.droppedContextLines = dropped;
|
|
15773
|
+
}
|
|
15774
|
+
}
|
|
15775
|
+
return result;
|
|
15776
|
+
}
|
|
15777
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
15778
|
+
const baseCounts = countLines(baseLines);
|
|
15779
|
+
const theirsCounts = countLines(theirsLines);
|
|
15780
|
+
const mergedCounts = countLines(mergedLines);
|
|
15781
|
+
const dropped = [];
|
|
15782
|
+
const seen = /* @__PURE__ */ new Set();
|
|
15783
|
+
for (const line of baseLines) {
|
|
15784
|
+
if (seen.has(line)) continue;
|
|
15785
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
15786
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
15787
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
15788
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
15789
|
+
dropped.push(line);
|
|
15790
|
+
seen.add(line);
|
|
15791
|
+
}
|
|
15792
|
+
}
|
|
15793
|
+
return dropped;
|
|
15794
|
+
}
|
|
15795
|
+
function countLines(lines) {
|
|
15796
|
+
const counts = /* @__PURE__ */ new Map();
|
|
15797
|
+
for (const line of lines) {
|
|
15798
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
15799
|
+
}
|
|
15800
|
+
return counts;
|
|
15769
15801
|
}
|
|
15770
15802
|
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
15771
15803
|
if (baseLines.length === 0) {
|
|
@@ -15881,6 +15913,19 @@ var ReplayApplicator = class {
|
|
|
15881
15913
|
renameCache = /* @__PURE__ */ new Map();
|
|
15882
15914
|
treeExistsCache = /* @__PURE__ */ new Map();
|
|
15883
15915
|
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
15916
|
+
/**
|
|
15917
|
+
* Apply mode for the current `applyPatches` invocation. Set at the start
|
|
15918
|
+
* of `applyPatches`, read by `mergeFile` to decide:
|
|
15919
|
+
* - whether to skip the intra-loop marker strip (kept in `applyPatches`)
|
|
15920
|
+
* - whether to populate the accumulator after a conflicted merge
|
|
15921
|
+
* (resolve mode populates so subsequent patches on the same file
|
|
15922
|
+
* can use patch A's THEIRS as a structurally-correct merge base
|
|
15923
|
+
* when their diff was authored against post-A structure)
|
|
15924
|
+
* - whether to retry THEIRS reconstruction against the accumulator
|
|
15925
|
+
* when the BASE-relative reconstruction produced markers from a
|
|
15926
|
+
* cross-patch context mismatch
|
|
15927
|
+
*/
|
|
15928
|
+
currentApplyMode = "replay";
|
|
15884
15929
|
constructor(git, lockManager, outputDir) {
|
|
15885
15930
|
this.git = git;
|
|
15886
15931
|
this.lockManager = lockManager;
|
|
@@ -15928,8 +15973,23 @@ var ReplayApplicator = class {
|
|
|
15928
15973
|
/**
|
|
15929
15974
|
* Apply all patches, returning results for each.
|
|
15930
15975
|
* Skips patches that match exclude patterns in replay.yml
|
|
15976
|
+
*
|
|
15977
|
+
* `applyMode` controls the post-conflict marker strategy:
|
|
15978
|
+
* - `"replay"` (default): strip conflict markers from disk between
|
|
15979
|
+
* iterations whenever a later patch touches the same file. Lets the
|
|
15980
|
+
* follow-up patch's `git apply --3way` see clean OURS content,
|
|
15981
|
+
* which is what the silent-loss fix (PR #73) relies on. The replay
|
|
15982
|
+
* pipeline calls `revertConflictingFiles` after the loop to clean
|
|
15983
|
+
* any markers that survive.
|
|
15984
|
+
* - `"resolve"`: keep markers on disk. The resolve command needs the
|
|
15985
|
+
* customer to see them. Slow-path 3-way merge naturally preserves
|
|
15986
|
+
* A's markers and B's clean writes when their regions don't overlap;
|
|
15987
|
+
* when they do overlap, the customer gets nested markers and
|
|
15988
|
+
* resolves manually. Either way they aren't silently dropped.
|
|
15931
15989
|
*/
|
|
15932
|
-
async applyPatches(patches) {
|
|
15990
|
+
async applyPatches(patches, opts) {
|
|
15991
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
15992
|
+
this.currentApplyMode = applyMode;
|
|
15933
15993
|
this.resetAccumulator();
|
|
15934
15994
|
const results = [];
|
|
15935
15995
|
for (let i = 0; i < patches.length; i++) {
|
|
@@ -15944,7 +16004,7 @@ var ReplayApplicator = class {
|
|
|
15944
16004
|
}
|
|
15945
16005
|
const result = await this.applyPatchWithFallback(patch);
|
|
15946
16006
|
results.push(result);
|
|
15947
|
-
if (result.status === "conflict" && result.fileResults) {
|
|
16007
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
15948
16008
|
const laterFiles = /* @__PURE__ */ new Set();
|
|
15949
16009
|
for (let j = i + 1; j < patches.length; j++) {
|
|
15950
16010
|
for (const f of patches[j].files) {
|
|
@@ -15974,9 +16034,17 @@ var ReplayApplicator = class {
|
|
|
15974
16034
|
}
|
|
15975
16035
|
return results;
|
|
15976
16036
|
}
|
|
15977
|
-
/**
|
|
16037
|
+
/**
|
|
16038
|
+
* Populate accumulator after git apply succeeds, AND collect per-file
|
|
16039
|
+
* results including any "dropped context lines" — lines that were
|
|
16040
|
+
* present in BASE and THEIRS (unchanged context) but absent from the
|
|
16041
|
+
* final on-disk state because OURS deleted them. This is the fast-path
|
|
16042
|
+
* counterpart to ThreeWayMerge.computeDroppedContextLines used by the
|
|
16043
|
+
* 3-way slow path; both must surface the same warning.
|
|
16044
|
+
*/
|
|
15978
16045
|
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
15979
|
-
|
|
16046
|
+
const fileResults = [];
|
|
16047
|
+
if (!baseGen) return fileResults;
|
|
15980
16048
|
const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path3.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
|
|
15981
16049
|
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
15982
16050
|
const tempGit = new GitClient2(tempDir);
|
|
@@ -15989,18 +16057,30 @@ var ReplayApplicator = class {
|
|
|
15989
16057
|
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
15990
16058
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
15991
16059
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
15992
|
-
const
|
|
16060
|
+
const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path3.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
16061
|
+
const effectiveTheirs = theirs ?? finalOnDisk;
|
|
15993
16062
|
if (effectiveTheirs != null) {
|
|
15994
16063
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
15995
16064
|
content: effectiveTheirs,
|
|
15996
16065
|
baseGeneration: patch.base_generation
|
|
15997
16066
|
});
|
|
15998
16067
|
}
|
|
16068
|
+
if (base != null && effectiveTheirs != null && finalOnDisk != null) {
|
|
16069
|
+
const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
|
|
16070
|
+
if (dropped.length > 0) {
|
|
16071
|
+
fileResults.push({
|
|
16072
|
+
file: resolvedPath,
|
|
16073
|
+
status: "merged",
|
|
16074
|
+
droppedContextLines: dropped
|
|
16075
|
+
});
|
|
16076
|
+
}
|
|
16077
|
+
}
|
|
15999
16078
|
}
|
|
16000
16079
|
} finally {
|
|
16001
16080
|
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
16002
16081
|
});
|
|
16003
16082
|
}
|
|
16083
|
+
return fileResults;
|
|
16004
16084
|
}
|
|
16005
16085
|
async applyPatchWithFallback(patch) {
|
|
16006
16086
|
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
@@ -16031,11 +16111,12 @@ var ReplayApplicator = class {
|
|
|
16031
16111
|
}
|
|
16032
16112
|
try {
|
|
16033
16113
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
16034
|
-
await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
16114
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
16035
16115
|
return {
|
|
16036
16116
|
patch,
|
|
16037
16117
|
status: "applied",
|
|
16038
16118
|
method: "git-am",
|
|
16119
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
16039
16120
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
16040
16121
|
};
|
|
16041
16122
|
} catch {
|
|
@@ -16147,19 +16228,23 @@ var ReplayApplicator = class {
|
|
|
16147
16228
|
renameSourcePath
|
|
16148
16229
|
);
|
|
16149
16230
|
}
|
|
16231
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
16150
16232
|
if (theirs) {
|
|
16151
16233
|
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
16152
16234
|
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
16153
16235
|
if (theirsHasMarkers && !baseHasMarkers) {
|
|
16154
|
-
|
|
16155
|
-
|
|
16156
|
-
|
|
16157
|
-
|
|
16158
|
-
|
|
16236
|
+
if (accumulatorEntry) {
|
|
16237
|
+
theirs = null;
|
|
16238
|
+
} else {
|
|
16239
|
+
return {
|
|
16240
|
+
file: resolvedPath,
|
|
16241
|
+
status: "skipped",
|
|
16242
|
+
reason: "stale-conflict-markers"
|
|
16243
|
+
};
|
|
16244
|
+
}
|
|
16159
16245
|
}
|
|
16160
16246
|
}
|
|
16161
16247
|
let useAccumulatorAsMergeBase = false;
|
|
16162
|
-
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
16163
16248
|
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
16164
16249
|
theirs = await this.applyPatchToContent(
|
|
16165
16250
|
accumulatorEntry.content,
|
|
@@ -16266,7 +16351,8 @@ var ReplayApplicator = class {
|
|
|
16266
16351
|
const outDir = (0, import_node_path3.dirname)(oursPath);
|
|
16267
16352
|
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
16268
16353
|
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
16269
|
-
|
|
16354
|
+
const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
|
|
16355
|
+
if (populateAccumulator) {
|
|
16270
16356
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
16271
16357
|
content: effective_theirs,
|
|
16272
16358
|
baseGeneration: patch.base_generation
|
|
@@ -16281,7 +16367,11 @@ var ReplayApplicator = class {
|
|
|
16281
16367
|
conflictMetadata: metadata
|
|
16282
16368
|
};
|
|
16283
16369
|
}
|
|
16284
|
-
return {
|
|
16370
|
+
return {
|
|
16371
|
+
file: resolvedPath,
|
|
16372
|
+
status: "merged",
|
|
16373
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
16374
|
+
};
|
|
16285
16375
|
} catch (error) {
|
|
16286
16376
|
return {
|
|
16287
16377
|
file: filePath,
|
|
@@ -16467,6 +16557,30 @@ function isBinaryFile(filePath) {
|
|
|
16467
16557
|
const ext2 = (0, import_node_path3.extname)(filePath).toLowerCase();
|
|
16468
16558
|
return BINARY_EXTENSIONS.has(ext2);
|
|
16469
16559
|
}
|
|
16560
|
+
function computeDroppedContextLinesForFile(base, theirs, finalContent) {
|
|
16561
|
+
const baseLines = base.split("\n");
|
|
16562
|
+
const theirsLines = theirs.split("\n");
|
|
16563
|
+
const finalLines = finalContent.split("\n");
|
|
16564
|
+
const baseCounts = /* @__PURE__ */ new Map();
|
|
16565
|
+
const theirsCounts = /* @__PURE__ */ new Map();
|
|
16566
|
+
const finalCounts = /* @__PURE__ */ new Map();
|
|
16567
|
+
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
16568
|
+
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
16569
|
+
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
16570
|
+
const dropped = [];
|
|
16571
|
+
const seen = /* @__PURE__ */ new Set();
|
|
16572
|
+
for (const line of baseLines) {
|
|
16573
|
+
if (seen.has(line)) continue;
|
|
16574
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
16575
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
16576
|
+
const inFinal = finalCounts.get(line) ?? 0;
|
|
16577
|
+
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
16578
|
+
dropped.push(line);
|
|
16579
|
+
seen.add(line);
|
|
16580
|
+
}
|
|
16581
|
+
}
|
|
16582
|
+
return dropped;
|
|
16583
|
+
}
|
|
16470
16584
|
function isDiffLineForFile(diffLine, filePath) {
|
|
16471
16585
|
const match2 = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
16472
16586
|
return match2 !== null && match2[1] === filePath;
|
|
@@ -16502,13 +16616,46 @@ CLI Version: ${options.cliVersion}`;
|
|
|
16502
16616
|
await this.git.exec(["commit", "-m", fullMessage]);
|
|
16503
16617
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
16504
16618
|
}
|
|
16505
|
-
async commitReplay(_patchCount, patches, message) {
|
|
16619
|
+
async commitReplay(_patchCount, patches, message, options) {
|
|
16506
16620
|
await this.stageAll();
|
|
16507
16621
|
if (!await this.hasStagedChanges()) {
|
|
16508
16622
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
16509
16623
|
}
|
|
16510
16624
|
let fullMessage = message ?? `[fern-replay] Applied customizations`;
|
|
16511
|
-
|
|
16625
|
+
const buckets = options?.buckets;
|
|
16626
|
+
if (buckets) {
|
|
16627
|
+
if (buckets.applied.length > 0) {
|
|
16628
|
+
fullMessage += `
|
|
16629
|
+
|
|
16630
|
+
Patches applied (${buckets.applied.length}):`;
|
|
16631
|
+
for (const p of buckets.applied) {
|
|
16632
|
+
fullMessage += `
|
|
16633
|
+
- ${p.id}: ${p.original_message}`;
|
|
16634
|
+
}
|
|
16635
|
+
}
|
|
16636
|
+
if (buckets.unresolved.length > 0) {
|
|
16637
|
+
fullMessage += `
|
|
16638
|
+
|
|
16639
|
+
Patches with unresolved conflicts (${buckets.unresolved.length}):`;
|
|
16640
|
+
for (const p of buckets.unresolved) {
|
|
16641
|
+
fullMessage += `
|
|
16642
|
+
- ${p.id}: ${p.original_message}`;
|
|
16643
|
+
}
|
|
16644
|
+
fullMessage += `
|
|
16645
|
+
Run \`fern-replay resolve\` to apply these customizations.`;
|
|
16646
|
+
}
|
|
16647
|
+
if (buckets.absorbed.length > 0) {
|
|
16648
|
+
fullMessage += `
|
|
16649
|
+
|
|
16650
|
+
Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
16651
|
+
for (const p of buckets.absorbed) {
|
|
16652
|
+
fullMessage += `
|
|
16653
|
+
- ${p.id}: ${p.original_message}`;
|
|
16654
|
+
}
|
|
16655
|
+
fullMessage += `
|
|
16656
|
+
The generator now produces these customizations natively.`;
|
|
16657
|
+
}
|
|
16658
|
+
} else if (patches && patches.length > 0) {
|
|
16512
16659
|
fullMessage += "\n\nPatches replayed:";
|
|
16513
16660
|
for (const patch of patches) {
|
|
16514
16661
|
fullMessage += `
|
|
@@ -16542,6 +16689,298 @@ CLI Version: ${options.cliVersion}`;
|
|
|
16542
16689
|
}
|
|
16543
16690
|
};
|
|
16544
16691
|
|
|
16692
|
+
// src/PatchRegionDiff.ts
|
|
16693
|
+
var import_promises2 = require("fs/promises");
|
|
16694
|
+
var import_node_os2 = require("os");
|
|
16695
|
+
var import_node_path4 = require("path");
|
|
16696
|
+
var import_node_child_process = require("child_process");
|
|
16697
|
+
init_HybridReconstruction();
|
|
16698
|
+
function normalizeHunkBody(hunk) {
|
|
16699
|
+
let contextC = 0;
|
|
16700
|
+
let removeC = 0;
|
|
16701
|
+
let addC = 0;
|
|
16702
|
+
const trimmed2 = [];
|
|
16703
|
+
for (const line of hunk.lines) {
|
|
16704
|
+
const oldUsed = contextC + removeC;
|
|
16705
|
+
const newUsed = contextC + addC;
|
|
16706
|
+
if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
|
|
16707
|
+
trimmed2.push(line);
|
|
16708
|
+
if (line.type === "context") contextC++;
|
|
16709
|
+
else if (line.type === "remove") removeC++;
|
|
16710
|
+
else if (line.type === "add") addC++;
|
|
16711
|
+
}
|
|
16712
|
+
return { ...hunk, lines: trimmed2 };
|
|
16713
|
+
}
|
|
16714
|
+
function extractFileDiffForFile(patchContent, filePath) {
|
|
16715
|
+
const lines = patchContent.split("\n");
|
|
16716
|
+
const out = [];
|
|
16717
|
+
let inTarget = false;
|
|
16718
|
+
for (const line of lines) {
|
|
16719
|
+
if (line.startsWith("diff --git")) {
|
|
16720
|
+
if (inTarget) break;
|
|
16721
|
+
if (isDiffLineForFile2(line, filePath)) {
|
|
16722
|
+
inTarget = true;
|
|
16723
|
+
out.push(line);
|
|
16724
|
+
}
|
|
16725
|
+
continue;
|
|
16726
|
+
}
|
|
16727
|
+
if (inTarget) out.push(line);
|
|
16728
|
+
}
|
|
16729
|
+
return out.length > 0 ? out.join("\n") + "\n" : null;
|
|
16730
|
+
}
|
|
16731
|
+
function isDiffLineForFile2(line, filePath) {
|
|
16732
|
+
const match2 = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
16733
|
+
return match2 !== null && (match2[2] === filePath || match2[1] === filePath);
|
|
16734
|
+
}
|
|
16735
|
+
async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
|
|
16736
|
+
const fragments = [];
|
|
16737
|
+
const unlocatableFiles = [];
|
|
16738
|
+
for (const file of patch.files) {
|
|
16739
|
+
const fileDiff = extractFileDiffForFile(patch.patch_content, file);
|
|
16740
|
+
if (fileDiff == null) {
|
|
16741
|
+
unlocatableFiles.push(file);
|
|
16742
|
+
continue;
|
|
16743
|
+
}
|
|
16744
|
+
const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
|
|
16745
|
+
const hunks = hunksRaw.filter((h) => {
|
|
16746
|
+
if (h.lines.length === 0) return false;
|
|
16747
|
+
let ctx = 0, rm3 = 0, add = 0;
|
|
16748
|
+
for (const ln of h.lines) {
|
|
16749
|
+
if (ln.type === "context") ctx++;
|
|
16750
|
+
else if (ln.type === "remove") rm3++;
|
|
16751
|
+
else if (ln.type === "add") add++;
|
|
16752
|
+
}
|
|
16753
|
+
return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
|
|
16754
|
+
});
|
|
16755
|
+
if (hunks.length === 0) {
|
|
16756
|
+
if (hunksRaw.length > 0) unlocatableFiles.push(file);
|
|
16757
|
+
continue;
|
|
16758
|
+
}
|
|
16759
|
+
const currentGen = await getCurrentGenContent(file);
|
|
16760
|
+
const workingTree = await getWorkingTreeContent(file);
|
|
16761
|
+
const isPureNewFile = hunks.every((h) => h.oldCount === 0);
|
|
16762
|
+
const isPureDelete = hunks.every((h) => h.newCount === 0);
|
|
16763
|
+
if (currentGen == null) {
|
|
16764
|
+
if (!isPureNewFile) {
|
|
16765
|
+
unlocatableFiles.push(file);
|
|
16766
|
+
continue;
|
|
16767
|
+
}
|
|
16768
|
+
if (workingTree == null) continue;
|
|
16769
|
+
fragments.push(synthesizeNewFileDiff(file, workingTree));
|
|
16770
|
+
continue;
|
|
16771
|
+
}
|
|
16772
|
+
if (isPureNewFile) {
|
|
16773
|
+
if (workingTree == null) {
|
|
16774
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
16775
|
+
continue;
|
|
16776
|
+
}
|
|
16777
|
+
if (workingTree === currentGen) {
|
|
16778
|
+
continue;
|
|
16779
|
+
}
|
|
16780
|
+
const fullDiff = await unifiedDiff(currentGen, workingTree, file);
|
|
16781
|
+
if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
|
|
16782
|
+
continue;
|
|
16783
|
+
}
|
|
16784
|
+
if (workingTree == null) {
|
|
16785
|
+
if (!isPureDelete) {
|
|
16786
|
+
unlocatableFiles.push(file);
|
|
16787
|
+
continue;
|
|
16788
|
+
}
|
|
16789
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
16790
|
+
continue;
|
|
16791
|
+
}
|
|
16792
|
+
const currentGenLines = splitLines(currentGen);
|
|
16793
|
+
const workingTreeLines = splitLines(workingTree);
|
|
16794
|
+
const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
|
|
16795
|
+
const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
|
|
16796
|
+
if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
|
|
16797
|
+
unlocatableFiles.push(file);
|
|
16798
|
+
continue;
|
|
16799
|
+
}
|
|
16800
|
+
const locInCurrentGen = locInCurrentGenRaw.map(
|
|
16801
|
+
(l) => resolveSpan(l, currentGenLines, "old")
|
|
16802
|
+
);
|
|
16803
|
+
const locInWorkingTree = locInWorkingTreeRaw.map(
|
|
16804
|
+
(l) => resolveSpan(l, workingTreeLines, "new")
|
|
16805
|
+
);
|
|
16806
|
+
const overlay = overlayRegions(
|
|
16807
|
+
currentGenLines,
|
|
16808
|
+
locInCurrentGen,
|
|
16809
|
+
workingTreeLines,
|
|
16810
|
+
locInWorkingTree
|
|
16811
|
+
);
|
|
16812
|
+
if (overlay === null) {
|
|
16813
|
+
unlocatableFiles.push(file);
|
|
16814
|
+
continue;
|
|
16815
|
+
}
|
|
16816
|
+
if (overlay === currentGen) {
|
|
16817
|
+
continue;
|
|
16818
|
+
}
|
|
16819
|
+
const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
|
|
16820
|
+
if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
|
|
16821
|
+
fragments.push(fileUnifiedDiff);
|
|
16822
|
+
}
|
|
16823
|
+
}
|
|
16824
|
+
return {
|
|
16825
|
+
diff: fragments.join(""),
|
|
16826
|
+
unlocatableFiles
|
|
16827
|
+
};
|
|
16828
|
+
}
|
|
16829
|
+
async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
|
|
16830
|
+
const ppDiff = await computePerPatchDiff(
|
|
16831
|
+
patch,
|
|
16832
|
+
getCurrentGenContent,
|
|
16833
|
+
getWorkingTreeContent
|
|
16834
|
+
);
|
|
16835
|
+
if (ppDiff.unlocatableFiles.length === 0) {
|
|
16836
|
+
return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
|
|
16837
|
+
}
|
|
16838
|
+
const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
|
|
16839
|
+
if (cumulative === null) {
|
|
16840
|
+
return null;
|
|
16841
|
+
}
|
|
16842
|
+
const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
|
|
16843
|
+
return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
|
|
16844
|
+
}
|
|
16845
|
+
function splitLines(content) {
|
|
16846
|
+
return content.split("\n");
|
|
16847
|
+
}
|
|
16848
|
+
function resolveSpan(loc, oursLines, prefer) {
|
|
16849
|
+
const { hunk, oursOffset } = loc;
|
|
16850
|
+
const oldSide = [];
|
|
16851
|
+
const newSide = [];
|
|
16852
|
+
for (const line of hunk.lines) {
|
|
16853
|
+
if (line.type === "context") {
|
|
16854
|
+
oldSide.push(line.content);
|
|
16855
|
+
newSide.push(line.content);
|
|
16856
|
+
} else if (line.type === "remove") {
|
|
16857
|
+
oldSide.push(line.content);
|
|
16858
|
+
} else if (line.type === "add") {
|
|
16859
|
+
newSide.push(line.content);
|
|
16860
|
+
}
|
|
16861
|
+
}
|
|
16862
|
+
const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
|
|
16863
|
+
const newFits = sequenceMatches(newSide, oursLines, oursOffset);
|
|
16864
|
+
if (prefer === "old") {
|
|
16865
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
16866
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
16867
|
+
} else {
|
|
16868
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
16869
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
16870
|
+
}
|
|
16871
|
+
return loc;
|
|
16872
|
+
}
|
|
16873
|
+
function sequenceMatches(needle, haystack, offset) {
|
|
16874
|
+
if (offset + needle.length > haystack.length) return false;
|
|
16875
|
+
for (let i = 0; i < needle.length; i++) {
|
|
16876
|
+
if (haystack[offset + i] !== needle[i]) return false;
|
|
16877
|
+
}
|
|
16878
|
+
return true;
|
|
16879
|
+
}
|
|
16880
|
+
function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
|
|
16881
|
+
if (locInCurrentGen.length !== locInWorkingTree.length) {
|
|
16882
|
+
return null;
|
|
16883
|
+
}
|
|
16884
|
+
const out = [];
|
|
16885
|
+
let cursor = 0;
|
|
16886
|
+
for (let i = 0; i < locInCurrentGen.length; i++) {
|
|
16887
|
+
const cg = locInCurrentGen[i];
|
|
16888
|
+
const wt = locInWorkingTree[i];
|
|
16889
|
+
if (cg.oursOffset > cursor) {
|
|
16890
|
+
out.push(...currentGenLines.slice(cursor, cg.oursOffset));
|
|
16891
|
+
}
|
|
16892
|
+
out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
|
|
16893
|
+
cursor = cg.oursOffset + cg.oursSpan;
|
|
16894
|
+
}
|
|
16895
|
+
if (cursor < currentGenLines.length) {
|
|
16896
|
+
out.push(...currentGenLines.slice(cursor));
|
|
16897
|
+
}
|
|
16898
|
+
return out.join("\n");
|
|
16899
|
+
}
|
|
16900
|
+
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
16901
|
+
if (beforeContent === afterContent) return "";
|
|
16902
|
+
const tmp = await (0, import_promises2.mkdtemp)((0, import_node_path4.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
|
|
16903
|
+
try {
|
|
16904
|
+
const beforePath = (0, import_node_path4.join)(tmp, "before");
|
|
16905
|
+
const afterPath = (0, import_node_path4.join)(tmp, "after");
|
|
16906
|
+
await (0, import_promises2.writeFile)(beforePath, beforeContent, "utf-8");
|
|
16907
|
+
await (0, import_promises2.writeFile)(afterPath, afterContent, "utf-8");
|
|
16908
|
+
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
16909
|
+
if (!raw.trim()) return "";
|
|
16910
|
+
return rewriteDiffHeaders(raw, filePath);
|
|
16911
|
+
} finally {
|
|
16912
|
+
await (0, import_promises2.rm)(tmp, { recursive: true, force: true });
|
|
16913
|
+
}
|
|
16914
|
+
}
|
|
16915
|
+
function runGitDiffNoIndex(beforePath, afterPath) {
|
|
16916
|
+
return new Promise((resolve2, reject) => {
|
|
16917
|
+
const proc = (0, import_node_child_process.spawn)("git", ["diff", "--no-index", "--", beforePath, afterPath]);
|
|
16918
|
+
let stdout = "";
|
|
16919
|
+
let stderr = "";
|
|
16920
|
+
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
16921
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
16922
|
+
proc.on("close", (code) => {
|
|
16923
|
+
if (code === 0 || code === 1) resolve2(stdout);
|
|
16924
|
+
else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
|
|
16925
|
+
});
|
|
16926
|
+
});
|
|
16927
|
+
}
|
|
16928
|
+
function rewriteDiffHeaders(raw, filePath) {
|
|
16929
|
+
const lines = raw.split("\n");
|
|
16930
|
+
const out = [];
|
|
16931
|
+
let seenFirstHeader = false;
|
|
16932
|
+
for (const line of lines) {
|
|
16933
|
+
if (line.startsWith("diff --git ")) {
|
|
16934
|
+
out.push(`diff --git a/${filePath} b/${filePath}`);
|
|
16935
|
+
seenFirstHeader = true;
|
|
16936
|
+
continue;
|
|
16937
|
+
}
|
|
16938
|
+
if (!seenFirstHeader) {
|
|
16939
|
+
continue;
|
|
16940
|
+
}
|
|
16941
|
+
if (line.startsWith("--- ")) {
|
|
16942
|
+
out.push(`--- a/${filePath}`);
|
|
16943
|
+
continue;
|
|
16944
|
+
}
|
|
16945
|
+
if (line.startsWith("+++ ")) {
|
|
16946
|
+
out.push(`+++ b/${filePath}`);
|
|
16947
|
+
continue;
|
|
16948
|
+
}
|
|
16949
|
+
out.push(line);
|
|
16950
|
+
}
|
|
16951
|
+
return out.join("\n");
|
|
16952
|
+
}
|
|
16953
|
+
function synthesizeNewFileDiff(file, content) {
|
|
16954
|
+
const lines = content.split("\n");
|
|
16955
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
16956
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
16957
|
+
const header = [
|
|
16958
|
+
`diff --git a/${file} b/${file}`,
|
|
16959
|
+
"new file mode 100644",
|
|
16960
|
+
"--- /dev/null",
|
|
16961
|
+
`+++ b/${file}`,
|
|
16962
|
+
`@@ -0,0 +1,${bodyLines.length} @@`
|
|
16963
|
+
].join("\n");
|
|
16964
|
+
const body = bodyLines.map((l) => `+${l}`).join("\n");
|
|
16965
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
16966
|
+
return header + "\n" + body + trailer + "\n";
|
|
16967
|
+
}
|
|
16968
|
+
function synthesizeDeleteFileDiff(file, content) {
|
|
16969
|
+
const lines = content.split("\n");
|
|
16970
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
16971
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
16972
|
+
const header = [
|
|
16973
|
+
`diff --git a/${file} b/${file}`,
|
|
16974
|
+
"deleted file mode 100644",
|
|
16975
|
+
`--- a/${file}`,
|
|
16976
|
+
"+++ /dev/null",
|
|
16977
|
+
`@@ -1,${bodyLines.length} +0,0 @@`
|
|
16978
|
+
].join("\n");
|
|
16979
|
+
const body = bodyLines.map((l) => `-${l}`).join("\n");
|
|
16980
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
16981
|
+
return header + "\n" + body + trailer + "\n";
|
|
16982
|
+
}
|
|
16983
|
+
|
|
16545
16984
|
// src/ReplayService.ts
|
|
16546
16985
|
var ReplayService = class {
|
|
16547
16986
|
git;
|
|
@@ -16936,6 +17375,7 @@ var ReplayService = class {
|
|
|
16936
17375
|
if (newPatches.length > 0) {
|
|
16937
17376
|
results = await this.applicator.applyPatches(newPatches);
|
|
16938
17377
|
this._lastApplyResults = results;
|
|
17378
|
+
this.recordDroppedContextLineWarnings(results);
|
|
16939
17379
|
this.revertConflictingFiles(results);
|
|
16940
17380
|
for (const result of results) {
|
|
16941
17381
|
if (result.status === "conflict") {
|
|
@@ -16954,7 +17394,8 @@ var ReplayService = class {
|
|
|
16954
17394
|
if (newPatches.length > 0) {
|
|
16955
17395
|
if (!options?.stageOnly) {
|
|
16956
17396
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
16957
|
-
|
|
17397
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
17398
|
+
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
16958
17399
|
} else {
|
|
16959
17400
|
await this.committer.stageAll();
|
|
16960
17401
|
}
|
|
@@ -17017,12 +17458,21 @@ var ReplayService = class {
|
|
|
17017
17458
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
17018
17459
|
);
|
|
17019
17460
|
existingPatches = this.lockManager.getPatches();
|
|
17020
|
-
const seenHashes = /* @__PURE__ */ new
|
|
17461
|
+
const seenHashes = /* @__PURE__ */ new Map();
|
|
17021
17462
|
for (const p of existingPatches) {
|
|
17022
|
-
|
|
17023
|
-
|
|
17463
|
+
const priorOrigin = seenHashes.get(p.content_hash);
|
|
17464
|
+
if (priorOrigin !== void 0) {
|
|
17465
|
+
const provenanceMissing = !priorOrigin || !p.original_commit;
|
|
17466
|
+
const sameOrigin = priorOrigin === p.original_commit;
|
|
17467
|
+
if (sameOrigin && !provenanceMissing) {
|
|
17468
|
+
this.lockManager.removePatch(p.id);
|
|
17469
|
+
} else {
|
|
17470
|
+
this.warnings.push(
|
|
17471
|
+
`Preserving patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
|
|
17472
|
+
);
|
|
17473
|
+
}
|
|
17024
17474
|
} else {
|
|
17025
|
-
seenHashes.
|
|
17475
|
+
seenHashes.set(p.content_hash, p.original_commit);
|
|
17026
17476
|
}
|
|
17027
17477
|
}
|
|
17028
17478
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -17083,6 +17533,7 @@ var ReplayService = class {
|
|
|
17083
17533
|
const genSha = prep._prepared.genSha;
|
|
17084
17534
|
const results = await this.applicator.applyPatches(allPatches);
|
|
17085
17535
|
this._lastApplyResults = results;
|
|
17536
|
+
this.recordDroppedContextLineWarnings(results);
|
|
17086
17537
|
this.revertConflictingFiles(results);
|
|
17087
17538
|
for (const result of results) {
|
|
17088
17539
|
if (result.status === "conflict") {
|
|
@@ -17110,7 +17561,8 @@ var ReplayService = class {
|
|
|
17110
17561
|
await this.committer.stageAll();
|
|
17111
17562
|
} else {
|
|
17112
17563
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
17113
|
-
|
|
17564
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
17565
|
+
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
17114
17566
|
}
|
|
17115
17567
|
const warnings = [...detectorWarnings, ...this.warnings];
|
|
17116
17568
|
return this.buildReport(
|
|
@@ -17134,7 +17586,7 @@ var ReplayService = class {
|
|
|
17134
17586
|
let repointed = 0;
|
|
17135
17587
|
let contentRebased = 0;
|
|
17136
17588
|
let keptAsUserOwned = 0;
|
|
17137
|
-
const seenContentHashes = /* @__PURE__ */ new
|
|
17589
|
+
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
17138
17590
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
17139
17591
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
17140
17592
|
for (const result of results) {
|
|
@@ -17148,6 +17600,23 @@ var ReplayService = class {
|
|
|
17148
17600
|
}
|
|
17149
17601
|
}
|
|
17150
17602
|
}
|
|
17603
|
+
const conflictedFilePaths = /* @__PURE__ */ new Set();
|
|
17604
|
+
for (const r of results) {
|
|
17605
|
+
if (r.status !== "conflict") continue;
|
|
17606
|
+
if (r.fileResults) {
|
|
17607
|
+
for (const fr of r.fileResults) {
|
|
17608
|
+
if (fr.status !== "conflict") continue;
|
|
17609
|
+
conflictedFilePaths.add(fr.file);
|
|
17610
|
+
if (r.resolvedFiles) {
|
|
17611
|
+
for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
|
|
17612
|
+
if (resolved === fr.file) conflictedFilePaths.add(orig);
|
|
17613
|
+
}
|
|
17614
|
+
}
|
|
17615
|
+
}
|
|
17616
|
+
} else {
|
|
17617
|
+
for (const f of r.patch.files) conflictedFilePaths.add(f);
|
|
17618
|
+
}
|
|
17619
|
+
}
|
|
17151
17620
|
for (const result of results) {
|
|
17152
17621
|
if (result.status === "conflict" && result.fileResults) {
|
|
17153
17622
|
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
@@ -17215,6 +17684,18 @@ var ReplayService = class {
|
|
|
17215
17684
|
keptAsUserOwned++;
|
|
17216
17685
|
continue;
|
|
17217
17686
|
}
|
|
17687
|
+
const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
|
|
17688
|
+
if (overlapsConflicted) {
|
|
17689
|
+
patch.status = "unresolved";
|
|
17690
|
+
try {
|
|
17691
|
+
this.lockManager.updatePatch(patch.id, { status: "unresolved" });
|
|
17692
|
+
} catch {
|
|
17693
|
+
}
|
|
17694
|
+
this.warnings.push(
|
|
17695
|
+
`Patch ${patch.id} (${patch.original_message}) was preserved as unresolved because an earlier patch conflicted on the same file(s) (${patch.files.join(", ")}). Run \`fern replay resolve\` to apply this customization manually.`
|
|
17696
|
+
);
|
|
17697
|
+
continue;
|
|
17698
|
+
}
|
|
17218
17699
|
this.lockManager.removePatch(patch.id);
|
|
17219
17700
|
absorbedPatchIds.add(patch.id);
|
|
17220
17701
|
absorbed++;
|
|
@@ -17230,13 +17711,22 @@ var ReplayService = class {
|
|
|
17230
17711
|
continue;
|
|
17231
17712
|
}
|
|
17232
17713
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
17233
|
-
|
|
17234
|
-
|
|
17235
|
-
|
|
17236
|
-
|
|
17237
|
-
|
|
17714
|
+
const priorOrigin = seenContentHashes.get(newContentHash);
|
|
17715
|
+
if (priorOrigin !== void 0) {
|
|
17716
|
+
const provenanceMissing = !priorOrigin || !patch.original_commit;
|
|
17717
|
+
const sameOrigin = priorOrigin === patch.original_commit;
|
|
17718
|
+
if (sameOrigin && !provenanceMissing) {
|
|
17719
|
+
this.lockManager.removePatch(patch.id);
|
|
17720
|
+
absorbedPatchIds.add(patch.id);
|
|
17721
|
+
absorbed++;
|
|
17722
|
+
continue;
|
|
17723
|
+
}
|
|
17724
|
+
this.warnings.push(
|
|
17725
|
+
`Preserving patch ${patch.id} (commit ${(patch.original_commit || "<missing>").slice(0, 7)}): shares content_hash with a prior patch ${provenanceMissing ? "(provenance missing on at least one side)" : "from a different source commit"}. Both retained to avoid silent customization loss (FER-9983).`
|
|
17726
|
+
);
|
|
17727
|
+
} else {
|
|
17728
|
+
seenContentHashes.set(newContentHash, patch.original_commit);
|
|
17238
17729
|
}
|
|
17239
|
-
seenContentHashes.add(newContentHash);
|
|
17240
17730
|
patch.base_generation = currentGenSha;
|
|
17241
17731
|
patch.patch_content = diff;
|
|
17242
17732
|
patch.content_hash = newContentHash;
|
|
@@ -17412,8 +17902,14 @@ var ReplayService = class {
|
|
|
17412
17902
|
}
|
|
17413
17903
|
if (patch.base_generation === currentGen) {
|
|
17414
17904
|
try {
|
|
17415
|
-
const
|
|
17416
|
-
|
|
17905
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
17906
|
+
patch,
|
|
17907
|
+
(f) => this.git.showFile(currentGen, f),
|
|
17908
|
+
(f) => this.git.showFile("HEAD", f),
|
|
17909
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
17910
|
+
);
|
|
17911
|
+
if (ppResult === null) continue;
|
|
17912
|
+
const diff = ppResult.diff;
|
|
17417
17913
|
if (!diff.trim()) {
|
|
17418
17914
|
if (await allPatchFilesUserOwned(patch)) continue;
|
|
17419
17915
|
this.lockManager.removePatch(patch.id);
|
|
@@ -17455,8 +17951,14 @@ var ReplayService = class {
|
|
|
17455
17951
|
try {
|
|
17456
17952
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
17457
17953
|
if (markerFiles.trim()) continue;
|
|
17458
|
-
const
|
|
17459
|
-
|
|
17954
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
17955
|
+
patch,
|
|
17956
|
+
(f) => this.git.showFile(currentGen, f),
|
|
17957
|
+
(f) => this.git.showFile("HEAD", f),
|
|
17958
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
17959
|
+
);
|
|
17960
|
+
if (ppResult === null) continue;
|
|
17961
|
+
const diff = ppResult.diff;
|
|
17460
17962
|
if (!diff.trim()) {
|
|
17461
17963
|
if (await allPatchFilesUserOwned(patch)) {
|
|
17462
17964
|
try {
|
|
@@ -17481,6 +17983,32 @@ var ReplayService = class {
|
|
|
17481
17983
|
}
|
|
17482
17984
|
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
17483
17985
|
}
|
|
17986
|
+
/**
|
|
17987
|
+
* After applyPatches(), surface a warning for each (patch × file) where
|
|
17988
|
+
* diff3 silently dropped lines that were unchanged context in the
|
|
17989
|
+
* customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
|
|
17990
|
+
* but the customer must learn so they can re-add the lines if they
|
|
17991
|
+
* relied on them. This is the "surface or preserve, never silently drop"
|
|
17992
|
+
* contract for legitimate-deletion cases.
|
|
17993
|
+
*/
|
|
17994
|
+
recordDroppedContextLineWarnings(results) {
|
|
17995
|
+
const PREVIEW_COUNT = 5;
|
|
17996
|
+
for (const result of results) {
|
|
17997
|
+
if (!result.fileResults) continue;
|
|
17998
|
+
for (const fr of result.fileResults) {
|
|
17999
|
+
const dropped = fr.droppedContextLines;
|
|
18000
|
+
if (!dropped || dropped.length === 0) continue;
|
|
18001
|
+
const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
|
|
18002
|
+
const more = dropped.length > PREVIEW_COUNT ? `
|
|
18003
|
+
... and ${dropped.length - PREVIEW_COUNT} more` : "";
|
|
18004
|
+
this.warnings.push(
|
|
18005
|
+
`${fr.file}: ${dropped.length} line(s) that appeared as unchanged context in your customization were deleted by the new generator output. The merge followed the deletion. First lines deleted:
|
|
18006
|
+
${preview}${more}
|
|
18007
|
+
If you want to keep these lines, restore them in a follow-up commit and re-run replay; otherwise this warning can be safely ignored.`
|
|
18008
|
+
);
|
|
18009
|
+
}
|
|
18010
|
+
}
|
|
18011
|
+
}
|
|
17484
18012
|
/**
|
|
17485
18013
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
17486
18014
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -17490,7 +18018,7 @@ var ReplayService = class {
|
|
|
17490
18018
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
17491
18019
|
for (const fileResult of result.fileResults) {
|
|
17492
18020
|
if (fileResult.status !== "conflict") continue;
|
|
17493
|
-
const filePath = (0,
|
|
18021
|
+
const filePath = (0, import_node_path5.join)(this.outputDir, fileResult.file);
|
|
17494
18022
|
try {
|
|
17495
18023
|
const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
|
|
17496
18024
|
const stripped = stripConflictMarkers(content);
|
|
@@ -17520,7 +18048,7 @@ var ReplayService = class {
|
|
|
17520
18048
|
}
|
|
17521
18049
|
}
|
|
17522
18050
|
readFernignorePatterns() {
|
|
17523
|
-
const fernignorePath = (0,
|
|
18051
|
+
const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
|
|
17524
18052
|
if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
|
|
17525
18053
|
return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
17526
18054
|
}
|
|
@@ -17538,6 +18066,9 @@ var ReplayService = class {
|
|
|
17538
18066
|
};
|
|
17539
18067
|
}).filter((d) => d.files.length > 0);
|
|
17540
18068
|
const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
|
|
18069
|
+
const unresolvedResults = results.filter(
|
|
18070
|
+
(r) => r.status === "conflict" || r.patch.status === "unresolved"
|
|
18071
|
+
);
|
|
17541
18072
|
return {
|
|
17542
18073
|
flow,
|
|
17543
18074
|
patchesDetected: patches.length,
|
|
@@ -17554,7 +18085,7 @@ var ReplayService = class {
|
|
|
17554
18085
|
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
17555
18086
|
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
17556
18087
|
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
17557
|
-
unresolvedPatches:
|
|
18088
|
+
unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
|
|
17558
18089
|
patchId: r.patch.id,
|
|
17559
18090
|
patchMessage: r.patch.original_message,
|
|
17560
18091
|
files: r.patch.files,
|
|
@@ -17565,11 +18096,38 @@ var ReplayService = class {
|
|
|
17565
18096
|
};
|
|
17566
18097
|
}
|
|
17567
18098
|
};
|
|
18099
|
+
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
18100
|
+
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
18101
|
+
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
18102
|
+
const applied = [];
|
|
18103
|
+
const unresolved = [];
|
|
18104
|
+
const absorbed = [];
|
|
18105
|
+
for (const patch of patchesPassedToCommit) {
|
|
18106
|
+
if (absorbedPatchIds.has(patch.id)) {
|
|
18107
|
+
absorbed.push(patch);
|
|
18108
|
+
continue;
|
|
18109
|
+
}
|
|
18110
|
+
const r = resultByPatchId.get(patch.id);
|
|
18111
|
+
if (!r) {
|
|
18112
|
+
applied.push(patch);
|
|
18113
|
+
continue;
|
|
18114
|
+
}
|
|
18115
|
+
if (r.status === "conflict" || patch.status === "unresolved") {
|
|
18116
|
+
unresolved.push(patch);
|
|
18117
|
+
continue;
|
|
18118
|
+
}
|
|
18119
|
+
if (r.status === "applied") {
|
|
18120
|
+
applied.push(patch);
|
|
18121
|
+
continue;
|
|
18122
|
+
}
|
|
18123
|
+
}
|
|
18124
|
+
return { applied, unresolved, absorbed };
|
|
18125
|
+
}
|
|
17568
18126
|
|
|
17569
18127
|
// src/FernignoreMigrator.ts
|
|
17570
18128
|
var import_node_crypto2 = require("crypto");
|
|
17571
18129
|
var import_node_fs3 = require("fs");
|
|
17572
|
-
var
|
|
18130
|
+
var import_node_path6 = require("path");
|
|
17573
18131
|
var import_yaml2 = __toESM(require_dist3(), 1);
|
|
17574
18132
|
var FernignoreMigrator = class {
|
|
17575
18133
|
git;
|
|
@@ -17581,10 +18139,10 @@ var FernignoreMigrator = class {
|
|
|
17581
18139
|
this.outputDir = outputDir;
|
|
17582
18140
|
}
|
|
17583
18141
|
fernignoreExists() {
|
|
17584
|
-
return (0, import_node_fs3.existsSync)((0,
|
|
18142
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(this.outputDir, ".fernignore"));
|
|
17585
18143
|
}
|
|
17586
18144
|
readFernignorePatterns() {
|
|
17587
|
-
const fernignorePath = (0,
|
|
18145
|
+
const fernignorePath = (0, import_node_path6.join)(this.outputDir, ".fernignore");
|
|
17588
18146
|
if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
|
|
17589
18147
|
return [];
|
|
17590
18148
|
}
|
|
@@ -17644,7 +18202,16 @@ var FernignoreMigrator = class {
|
|
|
17644
18202
|
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
17645
18203
|
base_generation: currentGen.commit_sha,
|
|
17646
18204
|
files: patchFiles,
|
|
17647
|
-
patch_content: patchContent
|
|
18205
|
+
patch_content: patchContent,
|
|
18206
|
+
// Synthetic patches captured from .fernignore-protected files
|
|
18207
|
+
// are user-owned by definition — the customer's content
|
|
18208
|
+
// diverges from the generator's pristine output, and the
|
|
18209
|
+
// generator never produced this divergent content. Without
|
|
18210
|
+
// this flag, removing the file from .fernignore later (so
|
|
18211
|
+
// it leaves the protection check in `isFileUserOwned`) would
|
|
18212
|
+
// expose the patch to absorption on the next regen,
|
|
18213
|
+
// silently losing the customization.
|
|
18214
|
+
user_owned: true
|
|
17648
18215
|
});
|
|
17649
18216
|
trackedByBoth.push({
|
|
17650
18217
|
file: pattern,
|
|
@@ -17677,7 +18244,7 @@ var FernignoreMigrator = class {
|
|
|
17677
18244
|
return results;
|
|
17678
18245
|
}
|
|
17679
18246
|
readFileContent(filePath) {
|
|
17680
|
-
const fullPath = (0,
|
|
18247
|
+
const fullPath = (0, import_node_path6.join)(this.outputDir, filePath);
|
|
17681
18248
|
if (!(0, import_node_fs3.existsSync)(fullPath)) {
|
|
17682
18249
|
return null;
|
|
17683
18250
|
}
|
|
@@ -17742,7 +18309,7 @@ var FernignoreMigrator = class {
|
|
|
17742
18309
|
};
|
|
17743
18310
|
}
|
|
17744
18311
|
movePatternsToReplayYml(patterns) {
|
|
17745
|
-
const replayYmlPath = (0,
|
|
18312
|
+
const replayYmlPath = (0, import_node_path6.join)(this.outputDir, ".fern", "replay.yml");
|
|
17746
18313
|
let config = {};
|
|
17747
18314
|
if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
|
|
17748
18315
|
const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
|
|
@@ -17751,7 +18318,7 @@ var FernignoreMigrator = class {
|
|
|
17751
18318
|
const existing = config.exclude ?? [];
|
|
17752
18319
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
17753
18320
|
config.exclude = merged;
|
|
17754
|
-
const dir = (0,
|
|
18321
|
+
const dir = (0, import_node_path6.dirname)(replayYmlPath);
|
|
17755
18322
|
if (!(0, import_node_fs3.existsSync)(dir)) {
|
|
17756
18323
|
(0, import_node_fs3.mkdirSync)(dir, { recursive: true });
|
|
17757
18324
|
}
|
|
@@ -17762,7 +18329,7 @@ var FernignoreMigrator = class {
|
|
|
17762
18329
|
// src/commands/bootstrap.ts
|
|
17763
18330
|
var import_node_crypto3 = require("crypto");
|
|
17764
18331
|
var import_node_fs4 = require("fs");
|
|
17765
|
-
var
|
|
18332
|
+
var import_node_path7 = require("path");
|
|
17766
18333
|
init_GitClient();
|
|
17767
18334
|
async function bootstrap(outputDir, options) {
|
|
17768
18335
|
const git = new GitClient(outputDir);
|
|
@@ -17929,7 +18496,7 @@ function parseGitLog(log) {
|
|
|
17929
18496
|
}
|
|
17930
18497
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
17931
18498
|
function ensureFernignoreEntries(outputDir) {
|
|
17932
|
-
const fernignorePath = (0,
|
|
18499
|
+
const fernignorePath = (0, import_node_path7.join)(outputDir, ".fernignore");
|
|
17933
18500
|
let content = "";
|
|
17934
18501
|
if ((0, import_node_fs4.existsSync)(fernignorePath)) {
|
|
17935
18502
|
content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
|
|
@@ -17953,7 +18520,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
17953
18520
|
}
|
|
17954
18521
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
17955
18522
|
function ensureGitattributesEntries(outputDir) {
|
|
17956
|
-
const gitattributesPath = (0,
|
|
18523
|
+
const gitattributesPath = (0, import_node_path7.join)(outputDir, ".gitattributes");
|
|
17957
18524
|
let content = "";
|
|
17958
18525
|
if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
|
|
17959
18526
|
content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
|
|
@@ -18150,6 +18717,8 @@ function reset(outputDir, options) {
|
|
|
18150
18717
|
}
|
|
18151
18718
|
|
|
18152
18719
|
// src/commands/resolve.ts
|
|
18720
|
+
var import_promises3 = require("fs/promises");
|
|
18721
|
+
var import_node_path8 = require("path");
|
|
18153
18722
|
init_GitClient();
|
|
18154
18723
|
async function resolve(outputDir, options) {
|
|
18155
18724
|
const lockManager = new LockfileManager(outputDir);
|
|
@@ -18165,7 +18734,7 @@ async function resolve(outputDir, options) {
|
|
|
18165
18734
|
const resolvingPatches = lockManager.getResolvingPatches();
|
|
18166
18735
|
if (unresolvedPatches.length > 0) {
|
|
18167
18736
|
const applicator = new ReplayApplicator(git, lockManager, outputDir);
|
|
18168
|
-
await applicator.applyPatches(unresolvedPatches);
|
|
18737
|
+
await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
|
|
18169
18738
|
const markerFiles = await findConflictMarkerFiles(git);
|
|
18170
18739
|
if (markerFiles.length > 0) {
|
|
18171
18740
|
for (const patch of unresolvedPatches) {
|
|
@@ -18191,20 +18760,38 @@ async function resolve(outputDir, options) {
|
|
|
18191
18760
|
if (patchesToCommit.length > 0) {
|
|
18192
18761
|
const currentGen = lock.current_generation;
|
|
18193
18762
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
18763
|
+
const warnings = [];
|
|
18194
18764
|
let patchesResolved = 0;
|
|
18195
18765
|
for (const patch of patchesToCommit) {
|
|
18196
|
-
const
|
|
18197
|
-
|
|
18766
|
+
const result = await computePerPatchDiffWithFallback(
|
|
18767
|
+
patch,
|
|
18768
|
+
(f) => git.showFile(currentGen, f),
|
|
18769
|
+
(f) => (0, import_promises3.readFile)((0, import_node_path8.join)(outputDir, f), "utf-8").catch(() => null),
|
|
18770
|
+
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
18771
|
+
);
|
|
18772
|
+
if (result === null) {
|
|
18773
|
+
warnings.push(
|
|
18774
|
+
`Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
|
|
18775
|
+
);
|
|
18776
|
+
continue;
|
|
18777
|
+
}
|
|
18778
|
+
const diff = result.diff;
|
|
18779
|
+
if (!diff.trim()) {
|
|
18198
18780
|
lockManager.removePatch(patch.id);
|
|
18199
18781
|
continue;
|
|
18200
18782
|
}
|
|
18783
|
+
if (result.hadFallback) {
|
|
18784
|
+
warnings.push(
|
|
18785
|
+
`Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
|
|
18786
|
+
);
|
|
18787
|
+
}
|
|
18201
18788
|
const newContentHash = detector.computeContentHash(diff);
|
|
18202
|
-
const changedFiles = await getChangedFiles(git, currentGen, patch.files);
|
|
18789
|
+
const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
|
|
18203
18790
|
lockManager.markPatchResolved(patch.id, {
|
|
18204
18791
|
patch_content: diff,
|
|
18205
18792
|
content_hash: newContentHash,
|
|
18206
18793
|
base_generation: currentGen,
|
|
18207
|
-
files: changedFiles
|
|
18794
|
+
files: changedFiles.length > 0 ? changedFiles : patch.files
|
|
18208
18795
|
});
|
|
18209
18796
|
patchesResolved++;
|
|
18210
18797
|
}
|
|
@@ -18220,7 +18807,8 @@ async function resolve(outputDir, options) {
|
|
|
18220
18807
|
success: true,
|
|
18221
18808
|
commitSha: commitSha2,
|
|
18222
18809
|
phase: "committed",
|
|
18223
|
-
patchesResolved
|
|
18810
|
+
patchesResolved,
|
|
18811
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
18224
18812
|
};
|
|
18225
18813
|
}
|
|
18226
18814
|
const committer = new ReplayCommitter(git, outputDir);
|
|
@@ -18238,6 +18826,24 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
18238
18826
|
const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
|
|
18239
18827
|
return changed.length > 0 ? changed : files;
|
|
18240
18828
|
}
|
|
18829
|
+
function extractFilesFromDiff(unifiedDiff2) {
|
|
18830
|
+
const out = /* @__PURE__ */ new Set();
|
|
18831
|
+
const renameSources = /* @__PURE__ */ new Set();
|
|
18832
|
+
let currentTarget = null;
|
|
18833
|
+
for (const line of unifiedDiff2.split("\n")) {
|
|
18834
|
+
const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
18835
|
+
if (headerMatch) {
|
|
18836
|
+
currentTarget = headerMatch[2];
|
|
18837
|
+
if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
|
|
18838
|
+
continue;
|
|
18839
|
+
}
|
|
18840
|
+
if (currentTarget && line.startsWith("rename from ")) {
|
|
18841
|
+
renameSources.add(line.slice("rename from ".length));
|
|
18842
|
+
}
|
|
18843
|
+
}
|
|
18844
|
+
for (const src of renameSources) out.delete(src);
|
|
18845
|
+
return Array.from(out);
|
|
18846
|
+
}
|
|
18241
18847
|
|
|
18242
18848
|
// src/commands/status.ts
|
|
18243
18849
|
function status(outputDir) {
|
|
@@ -18372,7 +18978,7 @@ function parseArgs(argv) {
|
|
|
18372
18978
|
positionals.push(arg);
|
|
18373
18979
|
}
|
|
18374
18980
|
}
|
|
18375
|
-
return { command, dir: (0,
|
|
18981
|
+
return { command, dir: (0, import_node_path9.resolve)(dir), flags, positionals };
|
|
18376
18982
|
}
|
|
18377
18983
|
async function runBootstrap(dir, flags) {
|
|
18378
18984
|
const dryRun = !!flags.dryRun;
|