@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/index.cjs
CHANGED
|
@@ -51,9 +51,9 @@ var init_GitClient = __esm({
|
|
|
51
51
|
return this.git.raw(args);
|
|
52
52
|
}
|
|
53
53
|
async execWithInput(args, input) {
|
|
54
|
-
const { spawn } = await import("child_process");
|
|
54
|
+
const { spawn: spawn2 } = await import("child_process");
|
|
55
55
|
return new Promise((resolve2, reject) => {
|
|
56
|
-
const proc =
|
|
56
|
+
const proc = spawn2("git", args, { cwd: this.repoPath });
|
|
57
57
|
let stdout = "";
|
|
58
58
|
let stderr = "";
|
|
59
59
|
proc.stdout.on("data", (data) => {
|
|
@@ -1312,11 +1312,43 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
1312
1312
|
}
|
|
1313
1313
|
}
|
|
1314
1314
|
}
|
|
1315
|
-
|
|
1315
|
+
const result = {
|
|
1316
1316
|
content: outputLines.join("\n"),
|
|
1317
1317
|
hasConflicts: conflicts.length > 0,
|
|
1318
1318
|
conflicts
|
|
1319
1319
|
};
|
|
1320
|
+
if (conflicts.length === 0) {
|
|
1321
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
1322
|
+
if (dropped.length > 0) {
|
|
1323
|
+
result.droppedContextLines = dropped;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return result;
|
|
1327
|
+
}
|
|
1328
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
1329
|
+
const baseCounts = countLines(baseLines);
|
|
1330
|
+
const theirsCounts = countLines(theirsLines);
|
|
1331
|
+
const mergedCounts = countLines(mergedLines);
|
|
1332
|
+
const dropped = [];
|
|
1333
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1334
|
+
for (const line of baseLines) {
|
|
1335
|
+
if (seen.has(line)) continue;
|
|
1336
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
1337
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
1338
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
1339
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
1340
|
+
dropped.push(line);
|
|
1341
|
+
seen.add(line);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return dropped;
|
|
1345
|
+
}
|
|
1346
|
+
function countLines(lines) {
|
|
1347
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1348
|
+
for (const line of lines) {
|
|
1349
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
1350
|
+
}
|
|
1351
|
+
return counts;
|
|
1320
1352
|
}
|
|
1321
1353
|
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1322
1354
|
if (baseLines.length === 0) {
|
|
@@ -1511,6 +1543,19 @@ var ReplayApplicator = class {
|
|
|
1511
1543
|
renameCache = /* @__PURE__ */ new Map();
|
|
1512
1544
|
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1513
1545
|
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
1546
|
+
/**
|
|
1547
|
+
* Apply mode for the current `applyPatches` invocation. Set at the start
|
|
1548
|
+
* of `applyPatches`, read by `mergeFile` to decide:
|
|
1549
|
+
* - whether to skip the intra-loop marker strip (kept in `applyPatches`)
|
|
1550
|
+
* - whether to populate the accumulator after a conflicted merge
|
|
1551
|
+
* (resolve mode populates so subsequent patches on the same file
|
|
1552
|
+
* can use patch A's THEIRS as a structurally-correct merge base
|
|
1553
|
+
* when their diff was authored against post-A structure)
|
|
1554
|
+
* - whether to retry THEIRS reconstruction against the accumulator
|
|
1555
|
+
* when the BASE-relative reconstruction produced markers from a
|
|
1556
|
+
* cross-patch context mismatch
|
|
1557
|
+
*/
|
|
1558
|
+
currentApplyMode = "replay";
|
|
1514
1559
|
constructor(git, lockManager, outputDir) {
|
|
1515
1560
|
this.git = git;
|
|
1516
1561
|
this.lockManager = lockManager;
|
|
@@ -1558,8 +1603,23 @@ var ReplayApplicator = class {
|
|
|
1558
1603
|
/**
|
|
1559
1604
|
* Apply all patches, returning results for each.
|
|
1560
1605
|
* Skips patches that match exclude patterns in replay.yml
|
|
1606
|
+
*
|
|
1607
|
+
* `applyMode` controls the post-conflict marker strategy:
|
|
1608
|
+
* - `"replay"` (default): strip conflict markers from disk between
|
|
1609
|
+
* iterations whenever a later patch touches the same file. Lets the
|
|
1610
|
+
* follow-up patch's `git apply --3way` see clean OURS content,
|
|
1611
|
+
* which is what the silent-loss fix (PR #73) relies on. The replay
|
|
1612
|
+
* pipeline calls `revertConflictingFiles` after the loop to clean
|
|
1613
|
+
* any markers that survive.
|
|
1614
|
+
* - `"resolve"`: keep markers on disk. The resolve command needs the
|
|
1615
|
+
* customer to see them. Slow-path 3-way merge naturally preserves
|
|
1616
|
+
* A's markers and B's clean writes when their regions don't overlap;
|
|
1617
|
+
* when they do overlap, the customer gets nested markers and
|
|
1618
|
+
* resolves manually. Either way they aren't silently dropped.
|
|
1561
1619
|
*/
|
|
1562
|
-
async applyPatches(patches) {
|
|
1620
|
+
async applyPatches(patches, opts) {
|
|
1621
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
1622
|
+
this.currentApplyMode = applyMode;
|
|
1563
1623
|
this.resetAccumulator();
|
|
1564
1624
|
const results = [];
|
|
1565
1625
|
for (let i = 0; i < patches.length; i++) {
|
|
@@ -1574,7 +1634,7 @@ var ReplayApplicator = class {
|
|
|
1574
1634
|
}
|
|
1575
1635
|
const result = await this.applyPatchWithFallback(patch);
|
|
1576
1636
|
results.push(result);
|
|
1577
|
-
if (result.status === "conflict" && result.fileResults) {
|
|
1637
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
1578
1638
|
const laterFiles = /* @__PURE__ */ new Set();
|
|
1579
1639
|
for (let j = i + 1; j < patches.length; j++) {
|
|
1580
1640
|
for (const f of patches[j].files) {
|
|
@@ -1604,9 +1664,17 @@ var ReplayApplicator = class {
|
|
|
1604
1664
|
}
|
|
1605
1665
|
return results;
|
|
1606
1666
|
}
|
|
1607
|
-
/**
|
|
1667
|
+
/**
|
|
1668
|
+
* Populate accumulator after git apply succeeds, AND collect per-file
|
|
1669
|
+
* results including any "dropped context lines" — lines that were
|
|
1670
|
+
* present in BASE and THEIRS (unchanged context) but absent from the
|
|
1671
|
+
* final on-disk state because OURS deleted them. This is the fast-path
|
|
1672
|
+
* counterpart to ThreeWayMerge.computeDroppedContextLines used by the
|
|
1673
|
+
* 3-way slow path; both must surface the same warning.
|
|
1674
|
+
*/
|
|
1608
1675
|
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
1609
|
-
|
|
1676
|
+
const fileResults = [];
|
|
1677
|
+
if (!baseGen) return fileResults;
|
|
1610
1678
|
const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
|
|
1611
1679
|
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1612
1680
|
const tempGit = new GitClient2(tempDir);
|
|
@@ -1619,18 +1687,30 @@ var ReplayApplicator = class {
|
|
|
1619
1687
|
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1620
1688
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1621
1689
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1622
|
-
const
|
|
1690
|
+
const finalOnDisk = await (0, import_promises.readFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1691
|
+
const effectiveTheirs = theirs ?? finalOnDisk;
|
|
1623
1692
|
if (effectiveTheirs != null) {
|
|
1624
1693
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1625
1694
|
content: effectiveTheirs,
|
|
1626
1695
|
baseGeneration: patch.base_generation
|
|
1627
1696
|
});
|
|
1628
1697
|
}
|
|
1698
|
+
if (base != null && effectiveTheirs != null && finalOnDisk != null) {
|
|
1699
|
+
const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
|
|
1700
|
+
if (dropped.length > 0) {
|
|
1701
|
+
fileResults.push({
|
|
1702
|
+
file: resolvedPath,
|
|
1703
|
+
status: "merged",
|
|
1704
|
+
droppedContextLines: dropped
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1629
1708
|
}
|
|
1630
1709
|
} finally {
|
|
1631
1710
|
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1632
1711
|
});
|
|
1633
1712
|
}
|
|
1713
|
+
return fileResults;
|
|
1634
1714
|
}
|
|
1635
1715
|
async applyPatchWithFallback(patch) {
|
|
1636
1716
|
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
@@ -1661,11 +1741,12 @@ var ReplayApplicator = class {
|
|
|
1661
1741
|
}
|
|
1662
1742
|
try {
|
|
1663
1743
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
1664
|
-
await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1744
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1665
1745
|
return {
|
|
1666
1746
|
patch,
|
|
1667
1747
|
status: "applied",
|
|
1668
1748
|
method: "git-am",
|
|
1749
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1669
1750
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1670
1751
|
};
|
|
1671
1752
|
} catch {
|
|
@@ -1777,19 +1858,23 @@ var ReplayApplicator = class {
|
|
|
1777
1858
|
renameSourcePath
|
|
1778
1859
|
);
|
|
1779
1860
|
}
|
|
1861
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1780
1862
|
if (theirs) {
|
|
1781
1863
|
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1782
1864
|
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1783
1865
|
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1866
|
+
if (accumulatorEntry) {
|
|
1867
|
+
theirs = null;
|
|
1868
|
+
} else {
|
|
1869
|
+
return {
|
|
1870
|
+
file: resolvedPath,
|
|
1871
|
+
status: "skipped",
|
|
1872
|
+
reason: "stale-conflict-markers"
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1789
1875
|
}
|
|
1790
1876
|
}
|
|
1791
1877
|
let useAccumulatorAsMergeBase = false;
|
|
1792
|
-
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1793
1878
|
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1794
1879
|
theirs = await this.applyPatchToContent(
|
|
1795
1880
|
accumulatorEntry.content,
|
|
@@ -1896,7 +1981,8 @@ var ReplayApplicator = class {
|
|
|
1896
1981
|
const outDir = (0, import_node_path2.dirname)(oursPath);
|
|
1897
1982
|
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
1898
1983
|
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
1899
|
-
|
|
1984
|
+
const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
|
|
1985
|
+
if (populateAccumulator) {
|
|
1900
1986
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1901
1987
|
content: effective_theirs,
|
|
1902
1988
|
baseGeneration: patch.base_generation
|
|
@@ -1911,7 +1997,11 @@ var ReplayApplicator = class {
|
|
|
1911
1997
|
conflictMetadata: metadata
|
|
1912
1998
|
};
|
|
1913
1999
|
}
|
|
1914
|
-
return {
|
|
2000
|
+
return {
|
|
2001
|
+
file: resolvedPath,
|
|
2002
|
+
status: "merged",
|
|
2003
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
2004
|
+
};
|
|
1915
2005
|
} catch (error) {
|
|
1916
2006
|
return {
|
|
1917
2007
|
file: filePath,
|
|
@@ -2097,6 +2187,30 @@ function isBinaryFile(filePath) {
|
|
|
2097
2187
|
const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
|
|
2098
2188
|
return BINARY_EXTENSIONS.has(ext);
|
|
2099
2189
|
}
|
|
2190
|
+
function computeDroppedContextLinesForFile(base, theirs, finalContent) {
|
|
2191
|
+
const baseLines = base.split("\n");
|
|
2192
|
+
const theirsLines = theirs.split("\n");
|
|
2193
|
+
const finalLines = finalContent.split("\n");
|
|
2194
|
+
const baseCounts = /* @__PURE__ */ new Map();
|
|
2195
|
+
const theirsCounts = /* @__PURE__ */ new Map();
|
|
2196
|
+
const finalCounts = /* @__PURE__ */ new Map();
|
|
2197
|
+
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
2198
|
+
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
2199
|
+
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
2200
|
+
const dropped = [];
|
|
2201
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2202
|
+
for (const line of baseLines) {
|
|
2203
|
+
if (seen.has(line)) continue;
|
|
2204
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
2205
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
2206
|
+
const inFinal = finalCounts.get(line) ?? 0;
|
|
2207
|
+
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
2208
|
+
dropped.push(line);
|
|
2209
|
+
seen.add(line);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return dropped;
|
|
2213
|
+
}
|
|
2100
2214
|
function isDiffLineForFile(diffLine, filePath) {
|
|
2101
2215
|
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
2102
2216
|
return match !== null && match[1] === filePath;
|
|
@@ -2132,13 +2246,46 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2132
2246
|
await this.git.exec(["commit", "-m", fullMessage]);
|
|
2133
2247
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2134
2248
|
}
|
|
2135
|
-
async commitReplay(_patchCount, patches, message) {
|
|
2249
|
+
async commitReplay(_patchCount, patches, message, options) {
|
|
2136
2250
|
await this.stageAll();
|
|
2137
2251
|
if (!await this.hasStagedChanges()) {
|
|
2138
2252
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2139
2253
|
}
|
|
2140
2254
|
let fullMessage = message ?? `[fern-replay] Applied customizations`;
|
|
2141
|
-
|
|
2255
|
+
const buckets = options?.buckets;
|
|
2256
|
+
if (buckets) {
|
|
2257
|
+
if (buckets.applied.length > 0) {
|
|
2258
|
+
fullMessage += `
|
|
2259
|
+
|
|
2260
|
+
Patches applied (${buckets.applied.length}):`;
|
|
2261
|
+
for (const p of buckets.applied) {
|
|
2262
|
+
fullMessage += `
|
|
2263
|
+
- ${p.id}: ${p.original_message}`;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
if (buckets.unresolved.length > 0) {
|
|
2267
|
+
fullMessage += `
|
|
2268
|
+
|
|
2269
|
+
Patches with unresolved conflicts (${buckets.unresolved.length}):`;
|
|
2270
|
+
for (const p of buckets.unresolved) {
|
|
2271
|
+
fullMessage += `
|
|
2272
|
+
- ${p.id}: ${p.original_message}`;
|
|
2273
|
+
}
|
|
2274
|
+
fullMessage += `
|
|
2275
|
+
Run \`fern-replay resolve\` to apply these customizations.`;
|
|
2276
|
+
}
|
|
2277
|
+
if (buckets.absorbed.length > 0) {
|
|
2278
|
+
fullMessage += `
|
|
2279
|
+
|
|
2280
|
+
Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
2281
|
+
for (const p of buckets.absorbed) {
|
|
2282
|
+
fullMessage += `
|
|
2283
|
+
- ${p.id}: ${p.original_message}`;
|
|
2284
|
+
}
|
|
2285
|
+
fullMessage += `
|
|
2286
|
+
The generator now produces these customizations natively.`;
|
|
2287
|
+
}
|
|
2288
|
+
} else if (patches && patches.length > 0) {
|
|
2142
2289
|
fullMessage += "\n\nPatches replayed:";
|
|
2143
2290
|
for (const patch of patches) {
|
|
2144
2291
|
fullMessage += `
|
|
@@ -2174,9 +2321,303 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2174
2321
|
|
|
2175
2322
|
// src/ReplayService.ts
|
|
2176
2323
|
var import_node_fs2 = require("fs");
|
|
2177
|
-
var
|
|
2324
|
+
var import_node_path4 = require("path");
|
|
2178
2325
|
var import_minimatch2 = require("minimatch");
|
|
2179
2326
|
init_GitClient();
|
|
2327
|
+
|
|
2328
|
+
// src/PatchRegionDiff.ts
|
|
2329
|
+
var import_promises2 = require("fs/promises");
|
|
2330
|
+
var import_node_os2 = require("os");
|
|
2331
|
+
var import_node_path3 = require("path");
|
|
2332
|
+
var import_node_child_process = require("child_process");
|
|
2333
|
+
init_HybridReconstruction();
|
|
2334
|
+
function normalizeHunkBody(hunk) {
|
|
2335
|
+
let contextC = 0;
|
|
2336
|
+
let removeC = 0;
|
|
2337
|
+
let addC = 0;
|
|
2338
|
+
const trimmed = [];
|
|
2339
|
+
for (const line of hunk.lines) {
|
|
2340
|
+
const oldUsed = contextC + removeC;
|
|
2341
|
+
const newUsed = contextC + addC;
|
|
2342
|
+
if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
|
|
2343
|
+
trimmed.push(line);
|
|
2344
|
+
if (line.type === "context") contextC++;
|
|
2345
|
+
else if (line.type === "remove") removeC++;
|
|
2346
|
+
else if (line.type === "add") addC++;
|
|
2347
|
+
}
|
|
2348
|
+
return { ...hunk, lines: trimmed };
|
|
2349
|
+
}
|
|
2350
|
+
function extractFileDiffForFile(patchContent, filePath) {
|
|
2351
|
+
const lines = patchContent.split("\n");
|
|
2352
|
+
const out = [];
|
|
2353
|
+
let inTarget = false;
|
|
2354
|
+
for (const line of lines) {
|
|
2355
|
+
if (line.startsWith("diff --git")) {
|
|
2356
|
+
if (inTarget) break;
|
|
2357
|
+
if (isDiffLineForFile2(line, filePath)) {
|
|
2358
|
+
inTarget = true;
|
|
2359
|
+
out.push(line);
|
|
2360
|
+
}
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
if (inTarget) out.push(line);
|
|
2364
|
+
}
|
|
2365
|
+
return out.length > 0 ? out.join("\n") + "\n" : null;
|
|
2366
|
+
}
|
|
2367
|
+
function isDiffLineForFile2(line, filePath) {
|
|
2368
|
+
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
2369
|
+
return match !== null && (match[2] === filePath || match[1] === filePath);
|
|
2370
|
+
}
|
|
2371
|
+
async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
|
|
2372
|
+
const fragments = [];
|
|
2373
|
+
const unlocatableFiles = [];
|
|
2374
|
+
for (const file of patch.files) {
|
|
2375
|
+
const fileDiff = extractFileDiffForFile(patch.patch_content, file);
|
|
2376
|
+
if (fileDiff == null) {
|
|
2377
|
+
unlocatableFiles.push(file);
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
|
|
2381
|
+
const hunks = hunksRaw.filter((h) => {
|
|
2382
|
+
if (h.lines.length === 0) return false;
|
|
2383
|
+
let ctx = 0, rm3 = 0, add = 0;
|
|
2384
|
+
for (const ln of h.lines) {
|
|
2385
|
+
if (ln.type === "context") ctx++;
|
|
2386
|
+
else if (ln.type === "remove") rm3++;
|
|
2387
|
+
else if (ln.type === "add") add++;
|
|
2388
|
+
}
|
|
2389
|
+
return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
|
|
2390
|
+
});
|
|
2391
|
+
if (hunks.length === 0) {
|
|
2392
|
+
if (hunksRaw.length > 0) unlocatableFiles.push(file);
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
const currentGen = await getCurrentGenContent(file);
|
|
2396
|
+
const workingTree = await getWorkingTreeContent(file);
|
|
2397
|
+
const isPureNewFile = hunks.every((h) => h.oldCount === 0);
|
|
2398
|
+
const isPureDelete = hunks.every((h) => h.newCount === 0);
|
|
2399
|
+
if (currentGen == null) {
|
|
2400
|
+
if (!isPureNewFile) {
|
|
2401
|
+
unlocatableFiles.push(file);
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
if (workingTree == null) continue;
|
|
2405
|
+
fragments.push(synthesizeNewFileDiff(file, workingTree));
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
if (isPureNewFile) {
|
|
2409
|
+
if (workingTree == null) {
|
|
2410
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
if (workingTree === currentGen) {
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
const fullDiff = await unifiedDiff(currentGen, workingTree, file);
|
|
2417
|
+
if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
|
|
2418
|
+
continue;
|
|
2419
|
+
}
|
|
2420
|
+
if (workingTree == null) {
|
|
2421
|
+
if (!isPureDelete) {
|
|
2422
|
+
unlocatableFiles.push(file);
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2426
|
+
continue;
|
|
2427
|
+
}
|
|
2428
|
+
const currentGenLines = splitLines(currentGen);
|
|
2429
|
+
const workingTreeLines = splitLines(workingTree);
|
|
2430
|
+
const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
|
|
2431
|
+
const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
|
|
2432
|
+
if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
|
|
2433
|
+
unlocatableFiles.push(file);
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
const locInCurrentGen = locInCurrentGenRaw.map(
|
|
2437
|
+
(l) => resolveSpan(l, currentGenLines, "old")
|
|
2438
|
+
);
|
|
2439
|
+
const locInWorkingTree = locInWorkingTreeRaw.map(
|
|
2440
|
+
(l) => resolveSpan(l, workingTreeLines, "new")
|
|
2441
|
+
);
|
|
2442
|
+
const overlay = overlayRegions(
|
|
2443
|
+
currentGenLines,
|
|
2444
|
+
locInCurrentGen,
|
|
2445
|
+
workingTreeLines,
|
|
2446
|
+
locInWorkingTree
|
|
2447
|
+
);
|
|
2448
|
+
if (overlay === null) {
|
|
2449
|
+
unlocatableFiles.push(file);
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
if (overlay === currentGen) {
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
|
|
2456
|
+
if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
|
|
2457
|
+
fragments.push(fileUnifiedDiff);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return {
|
|
2461
|
+
diff: fragments.join(""),
|
|
2462
|
+
unlocatableFiles
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
|
|
2466
|
+
const ppDiff = await computePerPatchDiff(
|
|
2467
|
+
patch,
|
|
2468
|
+
getCurrentGenContent,
|
|
2469
|
+
getWorkingTreeContent
|
|
2470
|
+
);
|
|
2471
|
+
if (ppDiff.unlocatableFiles.length === 0) {
|
|
2472
|
+
return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
|
|
2473
|
+
}
|
|
2474
|
+
const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
|
|
2475
|
+
if (cumulative === null) {
|
|
2476
|
+
return null;
|
|
2477
|
+
}
|
|
2478
|
+
const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
|
|
2479
|
+
return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
|
|
2480
|
+
}
|
|
2481
|
+
function splitLines(content) {
|
|
2482
|
+
return content.split("\n");
|
|
2483
|
+
}
|
|
2484
|
+
function resolveSpan(loc, oursLines, prefer) {
|
|
2485
|
+
const { hunk, oursOffset } = loc;
|
|
2486
|
+
const oldSide = [];
|
|
2487
|
+
const newSide = [];
|
|
2488
|
+
for (const line of hunk.lines) {
|
|
2489
|
+
if (line.type === "context") {
|
|
2490
|
+
oldSide.push(line.content);
|
|
2491
|
+
newSide.push(line.content);
|
|
2492
|
+
} else if (line.type === "remove") {
|
|
2493
|
+
oldSide.push(line.content);
|
|
2494
|
+
} else if (line.type === "add") {
|
|
2495
|
+
newSide.push(line.content);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
|
|
2499
|
+
const newFits = sequenceMatches(newSide, oursLines, oursOffset);
|
|
2500
|
+
if (prefer === "old") {
|
|
2501
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2502
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2503
|
+
} else {
|
|
2504
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2505
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2506
|
+
}
|
|
2507
|
+
return loc;
|
|
2508
|
+
}
|
|
2509
|
+
function sequenceMatches(needle, haystack, offset) {
|
|
2510
|
+
if (offset + needle.length > haystack.length) return false;
|
|
2511
|
+
for (let i = 0; i < needle.length; i++) {
|
|
2512
|
+
if (haystack[offset + i] !== needle[i]) return false;
|
|
2513
|
+
}
|
|
2514
|
+
return true;
|
|
2515
|
+
}
|
|
2516
|
+
function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
|
|
2517
|
+
if (locInCurrentGen.length !== locInWorkingTree.length) {
|
|
2518
|
+
return null;
|
|
2519
|
+
}
|
|
2520
|
+
const out = [];
|
|
2521
|
+
let cursor = 0;
|
|
2522
|
+
for (let i = 0; i < locInCurrentGen.length; i++) {
|
|
2523
|
+
const cg = locInCurrentGen[i];
|
|
2524
|
+
const wt = locInWorkingTree[i];
|
|
2525
|
+
if (cg.oursOffset > cursor) {
|
|
2526
|
+
out.push(...currentGenLines.slice(cursor, cg.oursOffset));
|
|
2527
|
+
}
|
|
2528
|
+
out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
|
|
2529
|
+
cursor = cg.oursOffset + cg.oursSpan;
|
|
2530
|
+
}
|
|
2531
|
+
if (cursor < currentGenLines.length) {
|
|
2532
|
+
out.push(...currentGenLines.slice(cursor));
|
|
2533
|
+
}
|
|
2534
|
+
return out.join("\n");
|
|
2535
|
+
}
|
|
2536
|
+
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2537
|
+
if (beforeContent === afterContent) return "";
|
|
2538
|
+
const tmp = await (0, import_promises2.mkdtemp)((0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "fern-replay-pp-"));
|
|
2539
|
+
try {
|
|
2540
|
+
const beforePath = (0, import_node_path3.join)(tmp, "before");
|
|
2541
|
+
const afterPath = (0, import_node_path3.join)(tmp, "after");
|
|
2542
|
+
await (0, import_promises2.writeFile)(beforePath, beforeContent, "utf-8");
|
|
2543
|
+
await (0, import_promises2.writeFile)(afterPath, afterContent, "utf-8");
|
|
2544
|
+
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
2545
|
+
if (!raw.trim()) return "";
|
|
2546
|
+
return rewriteDiffHeaders(raw, filePath);
|
|
2547
|
+
} finally {
|
|
2548
|
+
await (0, import_promises2.rm)(tmp, { recursive: true, force: true });
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
function runGitDiffNoIndex(beforePath, afterPath) {
|
|
2552
|
+
return new Promise((resolve2, reject) => {
|
|
2553
|
+
const proc = (0, import_node_child_process.spawn)("git", ["diff", "--no-index", "--", beforePath, afterPath]);
|
|
2554
|
+
let stdout = "";
|
|
2555
|
+
let stderr = "";
|
|
2556
|
+
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
2557
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
2558
|
+
proc.on("close", (code) => {
|
|
2559
|
+
if (code === 0 || code === 1) resolve2(stdout);
|
|
2560
|
+
else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
|
|
2561
|
+
});
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
function rewriteDiffHeaders(raw, filePath) {
|
|
2565
|
+
const lines = raw.split("\n");
|
|
2566
|
+
const out = [];
|
|
2567
|
+
let seenFirstHeader = false;
|
|
2568
|
+
for (const line of lines) {
|
|
2569
|
+
if (line.startsWith("diff --git ")) {
|
|
2570
|
+
out.push(`diff --git a/${filePath} b/${filePath}`);
|
|
2571
|
+
seenFirstHeader = true;
|
|
2572
|
+
continue;
|
|
2573
|
+
}
|
|
2574
|
+
if (!seenFirstHeader) {
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2577
|
+
if (line.startsWith("--- ")) {
|
|
2578
|
+
out.push(`--- a/${filePath}`);
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
2581
|
+
if (line.startsWith("+++ ")) {
|
|
2582
|
+
out.push(`+++ b/${filePath}`);
|
|
2583
|
+
continue;
|
|
2584
|
+
}
|
|
2585
|
+
out.push(line);
|
|
2586
|
+
}
|
|
2587
|
+
return out.join("\n");
|
|
2588
|
+
}
|
|
2589
|
+
function synthesizeNewFileDiff(file, content) {
|
|
2590
|
+
const lines = content.split("\n");
|
|
2591
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2592
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2593
|
+
const header = [
|
|
2594
|
+
`diff --git a/${file} b/${file}`,
|
|
2595
|
+
"new file mode 100644",
|
|
2596
|
+
"--- /dev/null",
|
|
2597
|
+
`+++ b/${file}`,
|
|
2598
|
+
`@@ -0,0 +1,${bodyLines.length} @@`
|
|
2599
|
+
].join("\n");
|
|
2600
|
+
const body = bodyLines.map((l) => `+${l}`).join("\n");
|
|
2601
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2602
|
+
return header + "\n" + body + trailer + "\n";
|
|
2603
|
+
}
|
|
2604
|
+
function synthesizeDeleteFileDiff(file, content) {
|
|
2605
|
+
const lines = content.split("\n");
|
|
2606
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2607
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2608
|
+
const header = [
|
|
2609
|
+
`diff --git a/${file} b/${file}`,
|
|
2610
|
+
"deleted file mode 100644",
|
|
2611
|
+
`--- a/${file}`,
|
|
2612
|
+
"+++ /dev/null",
|
|
2613
|
+
`@@ -1,${bodyLines.length} +0,0 @@`
|
|
2614
|
+
].join("\n");
|
|
2615
|
+
const body = bodyLines.map((l) => `-${l}`).join("\n");
|
|
2616
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2617
|
+
return header + "\n" + body + trailer + "\n";
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// src/ReplayService.ts
|
|
2180
2621
|
var ReplayService = class {
|
|
2181
2622
|
git;
|
|
2182
2623
|
detector;
|
|
@@ -2570,6 +3011,7 @@ var ReplayService = class {
|
|
|
2570
3011
|
if (newPatches.length > 0) {
|
|
2571
3012
|
results = await this.applicator.applyPatches(newPatches);
|
|
2572
3013
|
this._lastApplyResults = results;
|
|
3014
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2573
3015
|
this.revertConflictingFiles(results);
|
|
2574
3016
|
for (const result of results) {
|
|
2575
3017
|
if (result.status === "conflict") {
|
|
@@ -2588,7 +3030,8 @@ var ReplayService = class {
|
|
|
2588
3030
|
if (newPatches.length > 0) {
|
|
2589
3031
|
if (!options?.stageOnly) {
|
|
2590
3032
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2591
|
-
|
|
3033
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
3034
|
+
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
2592
3035
|
} else {
|
|
2593
3036
|
await this.committer.stageAll();
|
|
2594
3037
|
}
|
|
@@ -2651,12 +3094,21 @@ var ReplayService = class {
|
|
|
2651
3094
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
2652
3095
|
);
|
|
2653
3096
|
existingPatches = this.lockManager.getPatches();
|
|
2654
|
-
const seenHashes = /* @__PURE__ */ new
|
|
3097
|
+
const seenHashes = /* @__PURE__ */ new Map();
|
|
2655
3098
|
for (const p of existingPatches) {
|
|
2656
|
-
|
|
2657
|
-
|
|
3099
|
+
const priorOrigin = seenHashes.get(p.content_hash);
|
|
3100
|
+
if (priorOrigin !== void 0) {
|
|
3101
|
+
const provenanceMissing = !priorOrigin || !p.original_commit;
|
|
3102
|
+
const sameOrigin = priorOrigin === p.original_commit;
|
|
3103
|
+
if (sameOrigin && !provenanceMissing) {
|
|
3104
|
+
this.lockManager.removePatch(p.id);
|
|
3105
|
+
} else {
|
|
3106
|
+
this.warnings.push(
|
|
3107
|
+
`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).`
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
2658
3110
|
} else {
|
|
2659
|
-
seenHashes.
|
|
3111
|
+
seenHashes.set(p.content_hash, p.original_commit);
|
|
2660
3112
|
}
|
|
2661
3113
|
}
|
|
2662
3114
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -2717,6 +3169,7 @@ var ReplayService = class {
|
|
|
2717
3169
|
const genSha = prep._prepared.genSha;
|
|
2718
3170
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2719
3171
|
this._lastApplyResults = results;
|
|
3172
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2720
3173
|
this.revertConflictingFiles(results);
|
|
2721
3174
|
for (const result of results) {
|
|
2722
3175
|
if (result.status === "conflict") {
|
|
@@ -2744,7 +3197,8 @@ var ReplayService = class {
|
|
|
2744
3197
|
await this.committer.stageAll();
|
|
2745
3198
|
} else {
|
|
2746
3199
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2747
|
-
|
|
3200
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
3201
|
+
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
2748
3202
|
}
|
|
2749
3203
|
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2750
3204
|
return this.buildReport(
|
|
@@ -2768,7 +3222,7 @@ var ReplayService = class {
|
|
|
2768
3222
|
let repointed = 0;
|
|
2769
3223
|
let contentRebased = 0;
|
|
2770
3224
|
let keptAsUserOwned = 0;
|
|
2771
|
-
const seenContentHashes = /* @__PURE__ */ new
|
|
3225
|
+
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
2772
3226
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2773
3227
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2774
3228
|
for (const result of results) {
|
|
@@ -2782,6 +3236,23 @@ var ReplayService = class {
|
|
|
2782
3236
|
}
|
|
2783
3237
|
}
|
|
2784
3238
|
}
|
|
3239
|
+
const conflictedFilePaths = /* @__PURE__ */ new Set();
|
|
3240
|
+
for (const r of results) {
|
|
3241
|
+
if (r.status !== "conflict") continue;
|
|
3242
|
+
if (r.fileResults) {
|
|
3243
|
+
for (const fr of r.fileResults) {
|
|
3244
|
+
if (fr.status !== "conflict") continue;
|
|
3245
|
+
conflictedFilePaths.add(fr.file);
|
|
3246
|
+
if (r.resolvedFiles) {
|
|
3247
|
+
for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
|
|
3248
|
+
if (resolved === fr.file) conflictedFilePaths.add(orig);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
} else {
|
|
3253
|
+
for (const f of r.patch.files) conflictedFilePaths.add(f);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
2785
3256
|
for (const result of results) {
|
|
2786
3257
|
if (result.status === "conflict" && result.fileResults) {
|
|
2787
3258
|
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
@@ -2849,6 +3320,18 @@ var ReplayService = class {
|
|
|
2849
3320
|
keptAsUserOwned++;
|
|
2850
3321
|
continue;
|
|
2851
3322
|
}
|
|
3323
|
+
const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
|
|
3324
|
+
if (overlapsConflicted) {
|
|
3325
|
+
patch.status = "unresolved";
|
|
3326
|
+
try {
|
|
3327
|
+
this.lockManager.updatePatch(patch.id, { status: "unresolved" });
|
|
3328
|
+
} catch {
|
|
3329
|
+
}
|
|
3330
|
+
this.warnings.push(
|
|
3331
|
+
`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.`
|
|
3332
|
+
);
|
|
3333
|
+
continue;
|
|
3334
|
+
}
|
|
2852
3335
|
this.lockManager.removePatch(patch.id);
|
|
2853
3336
|
absorbedPatchIds.add(patch.id);
|
|
2854
3337
|
absorbed++;
|
|
@@ -2864,13 +3347,22 @@ var ReplayService = class {
|
|
|
2864
3347
|
continue;
|
|
2865
3348
|
}
|
|
2866
3349
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
3350
|
+
const priorOrigin = seenContentHashes.get(newContentHash);
|
|
3351
|
+
if (priorOrigin !== void 0) {
|
|
3352
|
+
const provenanceMissing = !priorOrigin || !patch.original_commit;
|
|
3353
|
+
const sameOrigin = priorOrigin === patch.original_commit;
|
|
3354
|
+
if (sameOrigin && !provenanceMissing) {
|
|
3355
|
+
this.lockManager.removePatch(patch.id);
|
|
3356
|
+
absorbedPatchIds.add(patch.id);
|
|
3357
|
+
absorbed++;
|
|
3358
|
+
continue;
|
|
3359
|
+
}
|
|
3360
|
+
this.warnings.push(
|
|
3361
|
+
`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).`
|
|
3362
|
+
);
|
|
3363
|
+
} else {
|
|
3364
|
+
seenContentHashes.set(newContentHash, patch.original_commit);
|
|
2872
3365
|
}
|
|
2873
|
-
seenContentHashes.add(newContentHash);
|
|
2874
3366
|
patch.base_generation = currentGenSha;
|
|
2875
3367
|
patch.patch_content = diff;
|
|
2876
3368
|
patch.content_hash = newContentHash;
|
|
@@ -3046,8 +3538,14 @@ var ReplayService = class {
|
|
|
3046
3538
|
}
|
|
3047
3539
|
if (patch.base_generation === currentGen) {
|
|
3048
3540
|
try {
|
|
3049
|
-
const
|
|
3050
|
-
|
|
3541
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3542
|
+
patch,
|
|
3543
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3544
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3545
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3546
|
+
);
|
|
3547
|
+
if (ppResult === null) continue;
|
|
3548
|
+
const diff = ppResult.diff;
|
|
3051
3549
|
if (!diff.trim()) {
|
|
3052
3550
|
if (await allPatchFilesUserOwned(patch)) continue;
|
|
3053
3551
|
this.lockManager.removePatch(patch.id);
|
|
@@ -3089,8 +3587,14 @@ var ReplayService = class {
|
|
|
3089
3587
|
try {
|
|
3090
3588
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
3091
3589
|
if (markerFiles.trim()) continue;
|
|
3092
|
-
const
|
|
3093
|
-
|
|
3590
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3591
|
+
patch,
|
|
3592
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3593
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3594
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3595
|
+
);
|
|
3596
|
+
if (ppResult === null) continue;
|
|
3597
|
+
const diff = ppResult.diff;
|
|
3094
3598
|
if (!diff.trim()) {
|
|
3095
3599
|
if (await allPatchFilesUserOwned(patch)) {
|
|
3096
3600
|
try {
|
|
@@ -3115,6 +3619,32 @@ var ReplayService = class {
|
|
|
3115
3619
|
}
|
|
3116
3620
|
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
3117
3621
|
}
|
|
3622
|
+
/**
|
|
3623
|
+
* After applyPatches(), surface a warning for each (patch × file) where
|
|
3624
|
+
* diff3 silently dropped lines that were unchanged context in the
|
|
3625
|
+
* customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
|
|
3626
|
+
* but the customer must learn so they can re-add the lines if they
|
|
3627
|
+
* relied on them. This is the "surface or preserve, never silently drop"
|
|
3628
|
+
* contract for legitimate-deletion cases.
|
|
3629
|
+
*/
|
|
3630
|
+
recordDroppedContextLineWarnings(results) {
|
|
3631
|
+
const PREVIEW_COUNT = 5;
|
|
3632
|
+
for (const result of results) {
|
|
3633
|
+
if (!result.fileResults) continue;
|
|
3634
|
+
for (const fr of result.fileResults) {
|
|
3635
|
+
const dropped = fr.droppedContextLines;
|
|
3636
|
+
if (!dropped || dropped.length === 0) continue;
|
|
3637
|
+
const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
|
|
3638
|
+
const more = dropped.length > PREVIEW_COUNT ? `
|
|
3639
|
+
... and ${dropped.length - PREVIEW_COUNT} more` : "";
|
|
3640
|
+
this.warnings.push(
|
|
3641
|
+
`${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:
|
|
3642
|
+
${preview}${more}
|
|
3643
|
+
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.`
|
|
3644
|
+
);
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3118
3648
|
/**
|
|
3119
3649
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
3120
3650
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -3124,7 +3654,7 @@ var ReplayService = class {
|
|
|
3124
3654
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3125
3655
|
for (const fileResult of result.fileResults) {
|
|
3126
3656
|
if (fileResult.status !== "conflict") continue;
|
|
3127
|
-
const filePath = (0,
|
|
3657
|
+
const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
|
|
3128
3658
|
try {
|
|
3129
3659
|
const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
|
|
3130
3660
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3154,7 +3684,7 @@ var ReplayService = class {
|
|
|
3154
3684
|
}
|
|
3155
3685
|
}
|
|
3156
3686
|
readFernignorePatterns() {
|
|
3157
|
-
const fernignorePath = (0,
|
|
3687
|
+
const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
|
|
3158
3688
|
if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
|
|
3159
3689
|
return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3160
3690
|
}
|
|
@@ -3172,6 +3702,9 @@ var ReplayService = class {
|
|
|
3172
3702
|
};
|
|
3173
3703
|
}).filter((d) => d.files.length > 0);
|
|
3174
3704
|
const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
|
|
3705
|
+
const unresolvedResults = results.filter(
|
|
3706
|
+
(r) => r.status === "conflict" || r.patch.status === "unresolved"
|
|
3707
|
+
);
|
|
3175
3708
|
return {
|
|
3176
3709
|
flow,
|
|
3177
3710
|
patchesDetected: patches.length,
|
|
@@ -3188,7 +3721,7 @@ var ReplayService = class {
|
|
|
3188
3721
|
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
3189
3722
|
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
3190
3723
|
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
3191
|
-
unresolvedPatches:
|
|
3724
|
+
unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
|
|
3192
3725
|
patchId: r.patch.id,
|
|
3193
3726
|
patchMessage: r.patch.original_message,
|
|
3194
3727
|
files: r.patch.files,
|
|
@@ -3199,11 +3732,38 @@ var ReplayService = class {
|
|
|
3199
3732
|
};
|
|
3200
3733
|
}
|
|
3201
3734
|
};
|
|
3735
|
+
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
3736
|
+
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
3737
|
+
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
3738
|
+
const applied = [];
|
|
3739
|
+
const unresolved = [];
|
|
3740
|
+
const absorbed = [];
|
|
3741
|
+
for (const patch of patchesPassedToCommit) {
|
|
3742
|
+
if (absorbedPatchIds.has(patch.id)) {
|
|
3743
|
+
absorbed.push(patch);
|
|
3744
|
+
continue;
|
|
3745
|
+
}
|
|
3746
|
+
const r = resultByPatchId.get(patch.id);
|
|
3747
|
+
if (!r) {
|
|
3748
|
+
applied.push(patch);
|
|
3749
|
+
continue;
|
|
3750
|
+
}
|
|
3751
|
+
if (r.status === "conflict" || patch.status === "unresolved") {
|
|
3752
|
+
unresolved.push(patch);
|
|
3753
|
+
continue;
|
|
3754
|
+
}
|
|
3755
|
+
if (r.status === "applied") {
|
|
3756
|
+
applied.push(patch);
|
|
3757
|
+
continue;
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
return { applied, unresolved, absorbed };
|
|
3761
|
+
}
|
|
3202
3762
|
|
|
3203
3763
|
// src/FernignoreMigrator.ts
|
|
3204
3764
|
var import_node_crypto2 = require("crypto");
|
|
3205
3765
|
var import_node_fs3 = require("fs");
|
|
3206
|
-
var
|
|
3766
|
+
var import_node_path5 = require("path");
|
|
3207
3767
|
var import_minimatch3 = require("minimatch");
|
|
3208
3768
|
var import_yaml2 = require("yaml");
|
|
3209
3769
|
var FernignoreMigrator = class {
|
|
@@ -3216,10 +3776,10 @@ var FernignoreMigrator = class {
|
|
|
3216
3776
|
this.outputDir = outputDir;
|
|
3217
3777
|
}
|
|
3218
3778
|
fernignoreExists() {
|
|
3219
|
-
return (0, import_node_fs3.existsSync)((0,
|
|
3779
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path5.join)(this.outputDir, ".fernignore"));
|
|
3220
3780
|
}
|
|
3221
3781
|
readFernignorePatterns() {
|
|
3222
|
-
const fernignorePath = (0,
|
|
3782
|
+
const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
|
|
3223
3783
|
if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
|
|
3224
3784
|
return [];
|
|
3225
3785
|
}
|
|
@@ -3279,7 +3839,16 @@ var FernignoreMigrator = class {
|
|
|
3279
3839
|
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
3280
3840
|
base_generation: currentGen.commit_sha,
|
|
3281
3841
|
files: patchFiles,
|
|
3282
|
-
patch_content: patchContent
|
|
3842
|
+
patch_content: patchContent,
|
|
3843
|
+
// Synthetic patches captured from .fernignore-protected files
|
|
3844
|
+
// are user-owned by definition — the customer's content
|
|
3845
|
+
// diverges from the generator's pristine output, and the
|
|
3846
|
+
// generator never produced this divergent content. Without
|
|
3847
|
+
// this flag, removing the file from .fernignore later (so
|
|
3848
|
+
// it leaves the protection check in `isFileUserOwned`) would
|
|
3849
|
+
// expose the patch to absorption on the next regen,
|
|
3850
|
+
// silently losing the customization.
|
|
3851
|
+
user_owned: true
|
|
3283
3852
|
});
|
|
3284
3853
|
trackedByBoth.push({
|
|
3285
3854
|
file: pattern,
|
|
@@ -3312,7 +3881,7 @@ var FernignoreMigrator = class {
|
|
|
3312
3881
|
return results;
|
|
3313
3882
|
}
|
|
3314
3883
|
readFileContent(filePath) {
|
|
3315
|
-
const fullPath = (0,
|
|
3884
|
+
const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
|
|
3316
3885
|
if (!(0, import_node_fs3.existsSync)(fullPath)) {
|
|
3317
3886
|
return null;
|
|
3318
3887
|
}
|
|
@@ -3377,7 +3946,7 @@ var FernignoreMigrator = class {
|
|
|
3377
3946
|
};
|
|
3378
3947
|
}
|
|
3379
3948
|
movePatternsToReplayYml(patterns) {
|
|
3380
|
-
const replayYmlPath = (0,
|
|
3949
|
+
const replayYmlPath = (0, import_node_path5.join)(this.outputDir, ".fern", "replay.yml");
|
|
3381
3950
|
let config = {};
|
|
3382
3951
|
if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
|
|
3383
3952
|
const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
|
|
@@ -3386,7 +3955,7 @@ var FernignoreMigrator = class {
|
|
|
3386
3955
|
const existing = config.exclude ?? [];
|
|
3387
3956
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
3388
3957
|
config.exclude = merged;
|
|
3389
|
-
const dir = (0,
|
|
3958
|
+
const dir = (0, import_node_path5.dirname)(replayYmlPath);
|
|
3390
3959
|
if (!(0, import_node_fs3.existsSync)(dir)) {
|
|
3391
3960
|
(0, import_node_fs3.mkdirSync)(dir, { recursive: true });
|
|
3392
3961
|
}
|
|
@@ -3397,7 +3966,7 @@ var FernignoreMigrator = class {
|
|
|
3397
3966
|
// src/commands/bootstrap.ts
|
|
3398
3967
|
var import_node_crypto3 = require("crypto");
|
|
3399
3968
|
var import_node_fs4 = require("fs");
|
|
3400
|
-
var
|
|
3969
|
+
var import_node_path6 = require("path");
|
|
3401
3970
|
init_GitClient();
|
|
3402
3971
|
async function bootstrap(outputDir, options) {
|
|
3403
3972
|
const git = new GitClient(outputDir);
|
|
@@ -3564,7 +4133,7 @@ function parseGitLog(log) {
|
|
|
3564
4133
|
}
|
|
3565
4134
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
3566
4135
|
function ensureFernignoreEntries(outputDir) {
|
|
3567
|
-
const fernignorePath = (0,
|
|
4136
|
+
const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
|
|
3568
4137
|
let content = "";
|
|
3569
4138
|
if ((0, import_node_fs4.existsSync)(fernignorePath)) {
|
|
3570
4139
|
content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
|
|
@@ -3588,7 +4157,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
3588
4157
|
}
|
|
3589
4158
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
3590
4159
|
function ensureGitattributesEntries(outputDir) {
|
|
3591
|
-
const gitattributesPath = (0,
|
|
4160
|
+
const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
|
|
3592
4161
|
let content = "";
|
|
3593
4162
|
if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
|
|
3594
4163
|
content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
|
|
@@ -3786,6 +4355,8 @@ function reset(outputDir, options) {
|
|
|
3786
4355
|
}
|
|
3787
4356
|
|
|
3788
4357
|
// src/commands/resolve.ts
|
|
4358
|
+
var import_promises3 = require("fs/promises");
|
|
4359
|
+
var import_node_path7 = require("path");
|
|
3789
4360
|
init_GitClient();
|
|
3790
4361
|
async function resolve(outputDir, options) {
|
|
3791
4362
|
const lockManager = new LockfileManager(outputDir);
|
|
@@ -3801,7 +4372,7 @@ async function resolve(outputDir, options) {
|
|
|
3801
4372
|
const resolvingPatches = lockManager.getResolvingPatches();
|
|
3802
4373
|
if (unresolvedPatches.length > 0) {
|
|
3803
4374
|
const applicator = new ReplayApplicator(git, lockManager, outputDir);
|
|
3804
|
-
await applicator.applyPatches(unresolvedPatches);
|
|
4375
|
+
await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
|
|
3805
4376
|
const markerFiles = await findConflictMarkerFiles(git);
|
|
3806
4377
|
if (markerFiles.length > 0) {
|
|
3807
4378
|
for (const patch of unresolvedPatches) {
|
|
@@ -3827,20 +4398,38 @@ async function resolve(outputDir, options) {
|
|
|
3827
4398
|
if (patchesToCommit.length > 0) {
|
|
3828
4399
|
const currentGen = lock.current_generation;
|
|
3829
4400
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4401
|
+
const warnings = [];
|
|
3830
4402
|
let patchesResolved = 0;
|
|
3831
4403
|
for (const patch of patchesToCommit) {
|
|
3832
|
-
const
|
|
3833
|
-
|
|
4404
|
+
const result = await computePerPatchDiffWithFallback(
|
|
4405
|
+
patch,
|
|
4406
|
+
(f) => git.showFile(currentGen, f),
|
|
4407
|
+
(f) => (0, import_promises3.readFile)((0, import_node_path7.join)(outputDir, f), "utf-8").catch(() => null),
|
|
4408
|
+
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
4409
|
+
);
|
|
4410
|
+
if (result === null) {
|
|
4411
|
+
warnings.push(
|
|
4412
|
+
`Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
|
|
4413
|
+
);
|
|
4414
|
+
continue;
|
|
4415
|
+
}
|
|
4416
|
+
const diff = result.diff;
|
|
4417
|
+
if (!diff.trim()) {
|
|
3834
4418
|
lockManager.removePatch(patch.id);
|
|
3835
4419
|
continue;
|
|
3836
4420
|
}
|
|
4421
|
+
if (result.hadFallback) {
|
|
4422
|
+
warnings.push(
|
|
4423
|
+
`Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
|
|
4424
|
+
);
|
|
4425
|
+
}
|
|
3837
4426
|
const newContentHash = detector.computeContentHash(diff);
|
|
3838
|
-
const changedFiles = await getChangedFiles(git, currentGen, patch.files);
|
|
4427
|
+
const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
|
|
3839
4428
|
lockManager.markPatchResolved(patch.id, {
|
|
3840
4429
|
patch_content: diff,
|
|
3841
4430
|
content_hash: newContentHash,
|
|
3842
4431
|
base_generation: currentGen,
|
|
3843
|
-
files: changedFiles
|
|
4432
|
+
files: changedFiles.length > 0 ? changedFiles : patch.files
|
|
3844
4433
|
});
|
|
3845
4434
|
patchesResolved++;
|
|
3846
4435
|
}
|
|
@@ -3856,7 +4445,8 @@ async function resolve(outputDir, options) {
|
|
|
3856
4445
|
success: true,
|
|
3857
4446
|
commitSha: commitSha2,
|
|
3858
4447
|
phase: "committed",
|
|
3859
|
-
patchesResolved
|
|
4448
|
+
patchesResolved,
|
|
4449
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
3860
4450
|
};
|
|
3861
4451
|
}
|
|
3862
4452
|
const committer = new ReplayCommitter(git, outputDir);
|
|
@@ -3874,6 +4464,24 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
3874
4464
|
const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
|
|
3875
4465
|
return changed.length > 0 ? changed : files;
|
|
3876
4466
|
}
|
|
4467
|
+
function extractFilesFromDiff(unifiedDiff2) {
|
|
4468
|
+
const out = /* @__PURE__ */ new Set();
|
|
4469
|
+
const renameSources = /* @__PURE__ */ new Set();
|
|
4470
|
+
let currentTarget = null;
|
|
4471
|
+
for (const line of unifiedDiff2.split("\n")) {
|
|
4472
|
+
const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
4473
|
+
if (headerMatch) {
|
|
4474
|
+
currentTarget = headerMatch[2];
|
|
4475
|
+
if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
|
|
4476
|
+
continue;
|
|
4477
|
+
}
|
|
4478
|
+
if (currentTarget && line.startsWith("rename from ")) {
|
|
4479
|
+
renameSources.add(line.slice("rename from ".length));
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
for (const src of renameSources) out.delete(src);
|
|
4483
|
+
return Array.from(out);
|
|
4484
|
+
}
|
|
3877
4485
|
|
|
3878
4486
|
// src/commands/status.ts
|
|
3879
4487
|
function status(outputDir) {
|