@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.js
CHANGED
|
@@ -29,9 +29,9 @@ var init_GitClient = __esm({
|
|
|
29
29
|
return this.git.raw(args);
|
|
30
30
|
}
|
|
31
31
|
async execWithInput(args, input) {
|
|
32
|
-
const { spawn } = await import("child_process");
|
|
32
|
+
const { spawn: spawn2 } = await import("child_process");
|
|
33
33
|
return new Promise((resolve2, reject) => {
|
|
34
|
-
const proc =
|
|
34
|
+
const proc = spawn2("git", args, { cwd: this.repoPath });
|
|
35
35
|
let stdout = "";
|
|
36
36
|
let stderr = "";
|
|
37
37
|
proc.stdout.on("data", (data) => {
|
|
@@ -1258,11 +1258,43 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
1258
1258
|
}
|
|
1259
1259
|
}
|
|
1260
1260
|
}
|
|
1261
|
-
|
|
1261
|
+
const result = {
|
|
1262
1262
|
content: outputLines.join("\n"),
|
|
1263
1263
|
hasConflicts: conflicts.length > 0,
|
|
1264
1264
|
conflicts
|
|
1265
1265
|
};
|
|
1266
|
+
if (conflicts.length === 0) {
|
|
1267
|
+
const dropped = computeDroppedContextLines(baseLines, theirsLines, outputLines);
|
|
1268
|
+
if (dropped.length > 0) {
|
|
1269
|
+
result.droppedContextLines = dropped;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return result;
|
|
1273
|
+
}
|
|
1274
|
+
function computeDroppedContextLines(baseLines, theirsLines, mergedLines) {
|
|
1275
|
+
const baseCounts = countLines(baseLines);
|
|
1276
|
+
const theirsCounts = countLines(theirsLines);
|
|
1277
|
+
const mergedCounts = countLines(mergedLines);
|
|
1278
|
+
const dropped = [];
|
|
1279
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1280
|
+
for (const line of baseLines) {
|
|
1281
|
+
if (seen.has(line)) continue;
|
|
1282
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
1283
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
1284
|
+
const inMerged = mergedCounts.get(line) ?? 0;
|
|
1285
|
+
if (inBase > 0 && inTheirs > 0 && inMerged < Math.min(inBase, inTheirs)) {
|
|
1286
|
+
dropped.push(line);
|
|
1287
|
+
seen.add(line);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return dropped;
|
|
1291
|
+
}
|
|
1292
|
+
function countLines(lines) {
|
|
1293
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1294
|
+
for (const line of lines) {
|
|
1295
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
1296
|
+
}
|
|
1297
|
+
return counts;
|
|
1266
1298
|
}
|
|
1267
1299
|
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1268
1300
|
if (baseLines.length === 0) {
|
|
@@ -1457,6 +1489,19 @@ var ReplayApplicator = class {
|
|
|
1457
1489
|
renameCache = /* @__PURE__ */ new Map();
|
|
1458
1490
|
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1459
1491
|
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
1492
|
+
/**
|
|
1493
|
+
* Apply mode for the current `applyPatches` invocation. Set at the start
|
|
1494
|
+
* of `applyPatches`, read by `mergeFile` to decide:
|
|
1495
|
+
* - whether to skip the intra-loop marker strip (kept in `applyPatches`)
|
|
1496
|
+
* - whether to populate the accumulator after a conflicted merge
|
|
1497
|
+
* (resolve mode populates so subsequent patches on the same file
|
|
1498
|
+
* can use patch A's THEIRS as a structurally-correct merge base
|
|
1499
|
+
* when their diff was authored against post-A structure)
|
|
1500
|
+
* - whether to retry THEIRS reconstruction against the accumulator
|
|
1501
|
+
* when the BASE-relative reconstruction produced markers from a
|
|
1502
|
+
* cross-patch context mismatch
|
|
1503
|
+
*/
|
|
1504
|
+
currentApplyMode = "replay";
|
|
1460
1505
|
constructor(git, lockManager, outputDir) {
|
|
1461
1506
|
this.git = git;
|
|
1462
1507
|
this.lockManager = lockManager;
|
|
@@ -1504,8 +1549,23 @@ var ReplayApplicator = class {
|
|
|
1504
1549
|
/**
|
|
1505
1550
|
* Apply all patches, returning results for each.
|
|
1506
1551
|
* Skips patches that match exclude patterns in replay.yml
|
|
1552
|
+
*
|
|
1553
|
+
* `applyMode` controls the post-conflict marker strategy:
|
|
1554
|
+
* - `"replay"` (default): strip conflict markers from disk between
|
|
1555
|
+
* iterations whenever a later patch touches the same file. Lets the
|
|
1556
|
+
* follow-up patch's `git apply --3way` see clean OURS content,
|
|
1557
|
+
* which is what the silent-loss fix (PR #73) relies on. The replay
|
|
1558
|
+
* pipeline calls `revertConflictingFiles` after the loop to clean
|
|
1559
|
+
* any markers that survive.
|
|
1560
|
+
* - `"resolve"`: keep markers on disk. The resolve command needs the
|
|
1561
|
+
* customer to see them. Slow-path 3-way merge naturally preserves
|
|
1562
|
+
* A's markers and B's clean writes when their regions don't overlap;
|
|
1563
|
+
* when they do overlap, the customer gets nested markers and
|
|
1564
|
+
* resolves manually. Either way they aren't silently dropped.
|
|
1507
1565
|
*/
|
|
1508
|
-
async applyPatches(patches) {
|
|
1566
|
+
async applyPatches(patches, opts) {
|
|
1567
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
1568
|
+
this.currentApplyMode = applyMode;
|
|
1509
1569
|
this.resetAccumulator();
|
|
1510
1570
|
const results = [];
|
|
1511
1571
|
for (let i = 0; i < patches.length; i++) {
|
|
@@ -1520,7 +1580,7 @@ var ReplayApplicator = class {
|
|
|
1520
1580
|
}
|
|
1521
1581
|
const result = await this.applyPatchWithFallback(patch);
|
|
1522
1582
|
results.push(result);
|
|
1523
|
-
if (result.status === "conflict" && result.fileResults) {
|
|
1583
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
1524
1584
|
const laterFiles = /* @__PURE__ */ new Set();
|
|
1525
1585
|
for (let j = i + 1; j < patches.length; j++) {
|
|
1526
1586
|
for (const f of patches[j].files) {
|
|
@@ -1550,9 +1610,17 @@ var ReplayApplicator = class {
|
|
|
1550
1610
|
}
|
|
1551
1611
|
return results;
|
|
1552
1612
|
}
|
|
1553
|
-
/**
|
|
1613
|
+
/**
|
|
1614
|
+
* Populate accumulator after git apply succeeds, AND collect per-file
|
|
1615
|
+
* results including any "dropped context lines" — lines that were
|
|
1616
|
+
* present in BASE and THEIRS (unchanged context) but absent from the
|
|
1617
|
+
* final on-disk state because OURS deleted them. This is the fast-path
|
|
1618
|
+
* counterpart to ThreeWayMerge.computeDroppedContextLines used by the
|
|
1619
|
+
* 3-way slow path; both must surface the same warning.
|
|
1620
|
+
*/
|
|
1554
1621
|
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
1555
|
-
|
|
1622
|
+
const fileResults = [];
|
|
1623
|
+
if (!baseGen) return fileResults;
|
|
1556
1624
|
const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
|
|
1557
1625
|
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1558
1626
|
const tempGit = new GitClient2(tempDir);
|
|
@@ -1565,18 +1633,30 @@ var ReplayApplicator = class {
|
|
|
1565
1633
|
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1566
1634
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1567
1635
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1568
|
-
const
|
|
1636
|
+
const finalOnDisk = await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1637
|
+
const effectiveTheirs = theirs ?? finalOnDisk;
|
|
1569
1638
|
if (effectiveTheirs != null) {
|
|
1570
1639
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1571
1640
|
content: effectiveTheirs,
|
|
1572
1641
|
baseGeneration: patch.base_generation
|
|
1573
1642
|
});
|
|
1574
1643
|
}
|
|
1644
|
+
if (base != null && effectiveTheirs != null && finalOnDisk != null) {
|
|
1645
|
+
const dropped = computeDroppedContextLinesForFile(base, effectiveTheirs, finalOnDisk);
|
|
1646
|
+
if (dropped.length > 0) {
|
|
1647
|
+
fileResults.push({
|
|
1648
|
+
file: resolvedPath,
|
|
1649
|
+
status: "merged",
|
|
1650
|
+
droppedContextLines: dropped
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1575
1654
|
}
|
|
1576
1655
|
} finally {
|
|
1577
1656
|
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1578
1657
|
});
|
|
1579
1658
|
}
|
|
1659
|
+
return fileResults;
|
|
1580
1660
|
}
|
|
1581
1661
|
async applyPatchWithFallback(patch) {
|
|
1582
1662
|
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
@@ -1607,11 +1687,12 @@ var ReplayApplicator = class {
|
|
|
1607
1687
|
}
|
|
1608
1688
|
try {
|
|
1609
1689
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
1610
|
-
await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1690
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1611
1691
|
return {
|
|
1612
1692
|
patch,
|
|
1613
1693
|
status: "applied",
|
|
1614
1694
|
method: "git-am",
|
|
1695
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1615
1696
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1616
1697
|
};
|
|
1617
1698
|
} catch {
|
|
@@ -1723,19 +1804,23 @@ var ReplayApplicator = class {
|
|
|
1723
1804
|
renameSourcePath
|
|
1724
1805
|
);
|
|
1725
1806
|
}
|
|
1807
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1726
1808
|
if (theirs) {
|
|
1727
1809
|
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1728
1810
|
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1729
1811
|
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1812
|
+
if (accumulatorEntry) {
|
|
1813
|
+
theirs = null;
|
|
1814
|
+
} else {
|
|
1815
|
+
return {
|
|
1816
|
+
file: resolvedPath,
|
|
1817
|
+
status: "skipped",
|
|
1818
|
+
reason: "stale-conflict-markers"
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1735
1821
|
}
|
|
1736
1822
|
}
|
|
1737
1823
|
let useAccumulatorAsMergeBase = false;
|
|
1738
|
-
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1739
1824
|
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1740
1825
|
theirs = await this.applyPatchToContent(
|
|
1741
1826
|
accumulatorEntry.content,
|
|
@@ -1842,7 +1927,8 @@ var ReplayApplicator = class {
|
|
|
1842
1927
|
const outDir = dirname2(oursPath);
|
|
1843
1928
|
await mkdir(outDir, { recursive: true });
|
|
1844
1929
|
await writeFile(oursPath, merged.content);
|
|
1845
|
-
|
|
1930
|
+
const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
|
|
1931
|
+
if (populateAccumulator) {
|
|
1846
1932
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1847
1933
|
content: effective_theirs,
|
|
1848
1934
|
baseGeneration: patch.base_generation
|
|
@@ -1857,7 +1943,11 @@ var ReplayApplicator = class {
|
|
|
1857
1943
|
conflictMetadata: metadata
|
|
1858
1944
|
};
|
|
1859
1945
|
}
|
|
1860
|
-
return {
|
|
1946
|
+
return {
|
|
1947
|
+
file: resolvedPath,
|
|
1948
|
+
status: "merged",
|
|
1949
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
1950
|
+
};
|
|
1861
1951
|
} catch (error) {
|
|
1862
1952
|
return {
|
|
1863
1953
|
file: filePath,
|
|
@@ -2043,6 +2133,30 @@ function isBinaryFile(filePath) {
|
|
|
2043
2133
|
const ext = extname(filePath).toLowerCase();
|
|
2044
2134
|
return BINARY_EXTENSIONS.has(ext);
|
|
2045
2135
|
}
|
|
2136
|
+
function computeDroppedContextLinesForFile(base, theirs, finalContent) {
|
|
2137
|
+
const baseLines = base.split("\n");
|
|
2138
|
+
const theirsLines = theirs.split("\n");
|
|
2139
|
+
const finalLines = finalContent.split("\n");
|
|
2140
|
+
const baseCounts = /* @__PURE__ */ new Map();
|
|
2141
|
+
const theirsCounts = /* @__PURE__ */ new Map();
|
|
2142
|
+
const finalCounts = /* @__PURE__ */ new Map();
|
|
2143
|
+
for (const l of baseLines) baseCounts.set(l, (baseCounts.get(l) ?? 0) + 1);
|
|
2144
|
+
for (const l of theirsLines) theirsCounts.set(l, (theirsCounts.get(l) ?? 0) + 1);
|
|
2145
|
+
for (const l of finalLines) finalCounts.set(l, (finalCounts.get(l) ?? 0) + 1);
|
|
2146
|
+
const dropped = [];
|
|
2147
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2148
|
+
for (const line of baseLines) {
|
|
2149
|
+
if (seen.has(line)) continue;
|
|
2150
|
+
const inBase = baseCounts.get(line) ?? 0;
|
|
2151
|
+
const inTheirs = theirsCounts.get(line) ?? 0;
|
|
2152
|
+
const inFinal = finalCounts.get(line) ?? 0;
|
|
2153
|
+
if (inBase > 0 && inTheirs > 0 && inFinal < Math.min(inBase, inTheirs)) {
|
|
2154
|
+
dropped.push(line);
|
|
2155
|
+
seen.add(line);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
return dropped;
|
|
2159
|
+
}
|
|
2046
2160
|
function isDiffLineForFile(diffLine, filePath) {
|
|
2047
2161
|
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
2048
2162
|
return match !== null && match[1] === filePath;
|
|
@@ -2078,13 +2192,46 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2078
2192
|
await this.git.exec(["commit", "-m", fullMessage]);
|
|
2079
2193
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2080
2194
|
}
|
|
2081
|
-
async commitReplay(_patchCount, patches, message) {
|
|
2195
|
+
async commitReplay(_patchCount, patches, message, options) {
|
|
2082
2196
|
await this.stageAll();
|
|
2083
2197
|
if (!await this.hasStagedChanges()) {
|
|
2084
2198
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2085
2199
|
}
|
|
2086
2200
|
let fullMessage = message ?? `[fern-replay] Applied customizations`;
|
|
2087
|
-
|
|
2201
|
+
const buckets = options?.buckets;
|
|
2202
|
+
if (buckets) {
|
|
2203
|
+
if (buckets.applied.length > 0) {
|
|
2204
|
+
fullMessage += `
|
|
2205
|
+
|
|
2206
|
+
Patches applied (${buckets.applied.length}):`;
|
|
2207
|
+
for (const p of buckets.applied) {
|
|
2208
|
+
fullMessage += `
|
|
2209
|
+
- ${p.id}: ${p.original_message}`;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
if (buckets.unresolved.length > 0) {
|
|
2213
|
+
fullMessage += `
|
|
2214
|
+
|
|
2215
|
+
Patches with unresolved conflicts (${buckets.unresolved.length}):`;
|
|
2216
|
+
for (const p of buckets.unresolved) {
|
|
2217
|
+
fullMessage += `
|
|
2218
|
+
- ${p.id}: ${p.original_message}`;
|
|
2219
|
+
}
|
|
2220
|
+
fullMessage += `
|
|
2221
|
+
Run \`fern-replay resolve\` to apply these customizations.`;
|
|
2222
|
+
}
|
|
2223
|
+
if (buckets.absorbed.length > 0) {
|
|
2224
|
+
fullMessage += `
|
|
2225
|
+
|
|
2226
|
+
Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
2227
|
+
for (const p of buckets.absorbed) {
|
|
2228
|
+
fullMessage += `
|
|
2229
|
+
- ${p.id}: ${p.original_message}`;
|
|
2230
|
+
}
|
|
2231
|
+
fullMessage += `
|
|
2232
|
+
The generator now produces these customizations natively.`;
|
|
2233
|
+
}
|
|
2234
|
+
} else if (patches && patches.length > 0) {
|
|
2088
2235
|
fullMessage += "\n\nPatches replayed:";
|
|
2089
2236
|
for (const patch of patches) {
|
|
2090
2237
|
fullMessage += `
|
|
@@ -2120,9 +2267,303 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2120
2267
|
|
|
2121
2268
|
// src/ReplayService.ts
|
|
2122
2269
|
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2123
|
-
import { join as
|
|
2270
|
+
import { join as join4 } from "path";
|
|
2124
2271
|
import { minimatch as minimatch2 } from "minimatch";
|
|
2125
2272
|
init_GitClient();
|
|
2273
|
+
|
|
2274
|
+
// src/PatchRegionDiff.ts
|
|
2275
|
+
init_HybridReconstruction();
|
|
2276
|
+
import { mkdtemp as mkdtemp2, writeFile as writeFile2, rm as rm2 } from "fs/promises";
|
|
2277
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2278
|
+
import { join as join3 } from "path";
|
|
2279
|
+
import { spawn } from "child_process";
|
|
2280
|
+
function normalizeHunkBody(hunk) {
|
|
2281
|
+
let contextC = 0;
|
|
2282
|
+
let removeC = 0;
|
|
2283
|
+
let addC = 0;
|
|
2284
|
+
const trimmed = [];
|
|
2285
|
+
for (const line of hunk.lines) {
|
|
2286
|
+
const oldUsed = contextC + removeC;
|
|
2287
|
+
const newUsed = contextC + addC;
|
|
2288
|
+
if (oldUsed >= hunk.oldCount && newUsed >= hunk.newCount) break;
|
|
2289
|
+
trimmed.push(line);
|
|
2290
|
+
if (line.type === "context") contextC++;
|
|
2291
|
+
else if (line.type === "remove") removeC++;
|
|
2292
|
+
else if (line.type === "add") addC++;
|
|
2293
|
+
}
|
|
2294
|
+
return { ...hunk, lines: trimmed };
|
|
2295
|
+
}
|
|
2296
|
+
function extractFileDiffForFile(patchContent, filePath) {
|
|
2297
|
+
const lines = patchContent.split("\n");
|
|
2298
|
+
const out = [];
|
|
2299
|
+
let inTarget = false;
|
|
2300
|
+
for (const line of lines) {
|
|
2301
|
+
if (line.startsWith("diff --git")) {
|
|
2302
|
+
if (inTarget) break;
|
|
2303
|
+
if (isDiffLineForFile2(line, filePath)) {
|
|
2304
|
+
inTarget = true;
|
|
2305
|
+
out.push(line);
|
|
2306
|
+
}
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
if (inTarget) out.push(line);
|
|
2310
|
+
}
|
|
2311
|
+
return out.length > 0 ? out.join("\n") + "\n" : null;
|
|
2312
|
+
}
|
|
2313
|
+
function isDiffLineForFile2(line, filePath) {
|
|
2314
|
+
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
2315
|
+
return match !== null && (match[2] === filePath || match[1] === filePath);
|
|
2316
|
+
}
|
|
2317
|
+
async function computePerPatchDiff(patch, getCurrentGenContent, getWorkingTreeContent) {
|
|
2318
|
+
const fragments = [];
|
|
2319
|
+
const unlocatableFiles = [];
|
|
2320
|
+
for (const file of patch.files) {
|
|
2321
|
+
const fileDiff = extractFileDiffForFile(patch.patch_content, file);
|
|
2322
|
+
if (fileDiff == null) {
|
|
2323
|
+
unlocatableFiles.push(file);
|
|
2324
|
+
continue;
|
|
2325
|
+
}
|
|
2326
|
+
const hunksRaw = parseHunks(fileDiff).map(normalizeHunkBody);
|
|
2327
|
+
const hunks = hunksRaw.filter((h) => {
|
|
2328
|
+
if (h.lines.length === 0) return false;
|
|
2329
|
+
let ctx = 0, rm3 = 0, add = 0;
|
|
2330
|
+
for (const ln of h.lines) {
|
|
2331
|
+
if (ln.type === "context") ctx++;
|
|
2332
|
+
else if (ln.type === "remove") rm3++;
|
|
2333
|
+
else if (ln.type === "add") add++;
|
|
2334
|
+
}
|
|
2335
|
+
return ctx + rm3 === h.oldCount && ctx + add === h.newCount;
|
|
2336
|
+
});
|
|
2337
|
+
if (hunks.length === 0) {
|
|
2338
|
+
if (hunksRaw.length > 0) unlocatableFiles.push(file);
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
const currentGen = await getCurrentGenContent(file);
|
|
2342
|
+
const workingTree = await getWorkingTreeContent(file);
|
|
2343
|
+
const isPureNewFile = hunks.every((h) => h.oldCount === 0);
|
|
2344
|
+
const isPureDelete = hunks.every((h) => h.newCount === 0);
|
|
2345
|
+
if (currentGen == null) {
|
|
2346
|
+
if (!isPureNewFile) {
|
|
2347
|
+
unlocatableFiles.push(file);
|
|
2348
|
+
continue;
|
|
2349
|
+
}
|
|
2350
|
+
if (workingTree == null) continue;
|
|
2351
|
+
fragments.push(synthesizeNewFileDiff(file, workingTree));
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
if (isPureNewFile) {
|
|
2355
|
+
if (workingTree == null) {
|
|
2356
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
if (workingTree === currentGen) {
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
const fullDiff = await unifiedDiff(currentGen, workingTree, file);
|
|
2363
|
+
if (fullDiff && fullDiff.trim()) fragments.push(fullDiff);
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
if (workingTree == null) {
|
|
2367
|
+
if (!isPureDelete) {
|
|
2368
|
+
unlocatableFiles.push(file);
|
|
2369
|
+
continue;
|
|
2370
|
+
}
|
|
2371
|
+
fragments.push(synthesizeDeleteFileDiff(file, currentGen));
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
const currentGenLines = splitLines(currentGen);
|
|
2375
|
+
const workingTreeLines = splitLines(workingTree);
|
|
2376
|
+
const locInCurrentGenRaw = locateHunksInOurs(hunks, currentGenLines);
|
|
2377
|
+
const locInWorkingTreeRaw = locateHunksInOurs(hunks, workingTreeLines);
|
|
2378
|
+
if (locInCurrentGenRaw == null || locInWorkingTreeRaw == null) {
|
|
2379
|
+
unlocatableFiles.push(file);
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
const locInCurrentGen = locInCurrentGenRaw.map(
|
|
2383
|
+
(l) => resolveSpan(l, currentGenLines, "old")
|
|
2384
|
+
);
|
|
2385
|
+
const locInWorkingTree = locInWorkingTreeRaw.map(
|
|
2386
|
+
(l) => resolveSpan(l, workingTreeLines, "new")
|
|
2387
|
+
);
|
|
2388
|
+
const overlay = overlayRegions(
|
|
2389
|
+
currentGenLines,
|
|
2390
|
+
locInCurrentGen,
|
|
2391
|
+
workingTreeLines,
|
|
2392
|
+
locInWorkingTree
|
|
2393
|
+
);
|
|
2394
|
+
if (overlay === null) {
|
|
2395
|
+
unlocatableFiles.push(file);
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
if (overlay === currentGen) {
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
const fileUnifiedDiff = await unifiedDiff(currentGen, overlay, file);
|
|
2402
|
+
if (fileUnifiedDiff && fileUnifiedDiff.trim()) {
|
|
2403
|
+
fragments.push(fileUnifiedDiff);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
return {
|
|
2407
|
+
diff: fragments.join(""),
|
|
2408
|
+
unlocatableFiles
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
async function computePerPatchDiffWithFallback(patch, getCurrentGenContent, getWorkingTreeContent, runCumulativeDiff) {
|
|
2412
|
+
const ppDiff = await computePerPatchDiff(
|
|
2413
|
+
patch,
|
|
2414
|
+
getCurrentGenContent,
|
|
2415
|
+
getWorkingTreeContent
|
|
2416
|
+
);
|
|
2417
|
+
if (ppDiff.unlocatableFiles.length === 0) {
|
|
2418
|
+
return { diff: ppDiff.diff, hadFallback: false, fallbackFiles: [] };
|
|
2419
|
+
}
|
|
2420
|
+
const cumulative = await runCumulativeDiff(ppDiff.unlocatableFiles);
|
|
2421
|
+
if (cumulative === null) {
|
|
2422
|
+
return null;
|
|
2423
|
+
}
|
|
2424
|
+
const merged = ppDiff.diff + (cumulative.trim() ? cumulative : "");
|
|
2425
|
+
return { diff: merged, hadFallback: true, fallbackFiles: ppDiff.unlocatableFiles };
|
|
2426
|
+
}
|
|
2427
|
+
function splitLines(content) {
|
|
2428
|
+
return content.split("\n");
|
|
2429
|
+
}
|
|
2430
|
+
function resolveSpan(loc, oursLines, prefer) {
|
|
2431
|
+
const { hunk, oursOffset } = loc;
|
|
2432
|
+
const oldSide = [];
|
|
2433
|
+
const newSide = [];
|
|
2434
|
+
for (const line of hunk.lines) {
|
|
2435
|
+
if (line.type === "context") {
|
|
2436
|
+
oldSide.push(line.content);
|
|
2437
|
+
newSide.push(line.content);
|
|
2438
|
+
} else if (line.type === "remove") {
|
|
2439
|
+
oldSide.push(line.content);
|
|
2440
|
+
} else if (line.type === "add") {
|
|
2441
|
+
newSide.push(line.content);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
const oldFits = sequenceMatches(oldSide, oursLines, oursOffset);
|
|
2445
|
+
const newFits = sequenceMatches(newSide, oursLines, oursOffset);
|
|
2446
|
+
if (prefer === "old") {
|
|
2447
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2448
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2449
|
+
} else {
|
|
2450
|
+
if (newFits) return { ...loc, oursSpan: hunk.newCount };
|
|
2451
|
+
if (oldFits) return { ...loc, oursSpan: hunk.oldCount };
|
|
2452
|
+
}
|
|
2453
|
+
return loc;
|
|
2454
|
+
}
|
|
2455
|
+
function sequenceMatches(needle, haystack, offset) {
|
|
2456
|
+
if (offset + needle.length > haystack.length) return false;
|
|
2457
|
+
for (let i = 0; i < needle.length; i++) {
|
|
2458
|
+
if (haystack[offset + i] !== needle[i]) return false;
|
|
2459
|
+
}
|
|
2460
|
+
return true;
|
|
2461
|
+
}
|
|
2462
|
+
function overlayRegions(currentGenLines, locInCurrentGen, workingTreeLines, locInWorkingTree) {
|
|
2463
|
+
if (locInCurrentGen.length !== locInWorkingTree.length) {
|
|
2464
|
+
return null;
|
|
2465
|
+
}
|
|
2466
|
+
const out = [];
|
|
2467
|
+
let cursor = 0;
|
|
2468
|
+
for (let i = 0; i < locInCurrentGen.length; i++) {
|
|
2469
|
+
const cg = locInCurrentGen[i];
|
|
2470
|
+
const wt = locInWorkingTree[i];
|
|
2471
|
+
if (cg.oursOffset > cursor) {
|
|
2472
|
+
out.push(...currentGenLines.slice(cursor, cg.oursOffset));
|
|
2473
|
+
}
|
|
2474
|
+
out.push(...workingTreeLines.slice(wt.oursOffset, wt.oursOffset + wt.oursSpan));
|
|
2475
|
+
cursor = cg.oursOffset + cg.oursSpan;
|
|
2476
|
+
}
|
|
2477
|
+
if (cursor < currentGenLines.length) {
|
|
2478
|
+
out.push(...currentGenLines.slice(cursor));
|
|
2479
|
+
}
|
|
2480
|
+
return out.join("\n");
|
|
2481
|
+
}
|
|
2482
|
+
async function unifiedDiff(beforeContent, afterContent, filePath) {
|
|
2483
|
+
if (beforeContent === afterContent) return "";
|
|
2484
|
+
const tmp = await mkdtemp2(join3(tmpdir2(), "fern-replay-pp-"));
|
|
2485
|
+
try {
|
|
2486
|
+
const beforePath = join3(tmp, "before");
|
|
2487
|
+
const afterPath = join3(tmp, "after");
|
|
2488
|
+
await writeFile2(beforePath, beforeContent, "utf-8");
|
|
2489
|
+
await writeFile2(afterPath, afterContent, "utf-8");
|
|
2490
|
+
const raw = await runGitDiffNoIndex(beforePath, afterPath);
|
|
2491
|
+
if (!raw.trim()) return "";
|
|
2492
|
+
return rewriteDiffHeaders(raw, filePath);
|
|
2493
|
+
} finally {
|
|
2494
|
+
await rm2(tmp, { recursive: true, force: true });
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
function runGitDiffNoIndex(beforePath, afterPath) {
|
|
2498
|
+
return new Promise((resolve2, reject) => {
|
|
2499
|
+
const proc = spawn("git", ["diff", "--no-index", "--", beforePath, afterPath]);
|
|
2500
|
+
let stdout = "";
|
|
2501
|
+
let stderr = "";
|
|
2502
|
+
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
2503
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
2504
|
+
proc.on("close", (code) => {
|
|
2505
|
+
if (code === 0 || code === 1) resolve2(stdout);
|
|
2506
|
+
else reject(new Error(`git diff --no-index failed (code ${code}): ${stderr}`));
|
|
2507
|
+
});
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
function rewriteDiffHeaders(raw, filePath) {
|
|
2511
|
+
const lines = raw.split("\n");
|
|
2512
|
+
const out = [];
|
|
2513
|
+
let seenFirstHeader = false;
|
|
2514
|
+
for (const line of lines) {
|
|
2515
|
+
if (line.startsWith("diff --git ")) {
|
|
2516
|
+
out.push(`diff --git a/${filePath} b/${filePath}`);
|
|
2517
|
+
seenFirstHeader = true;
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
if (!seenFirstHeader) {
|
|
2521
|
+
continue;
|
|
2522
|
+
}
|
|
2523
|
+
if (line.startsWith("--- ")) {
|
|
2524
|
+
out.push(`--- a/${filePath}`);
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
if (line.startsWith("+++ ")) {
|
|
2528
|
+
out.push(`+++ b/${filePath}`);
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
out.push(line);
|
|
2532
|
+
}
|
|
2533
|
+
return out.join("\n");
|
|
2534
|
+
}
|
|
2535
|
+
function synthesizeNewFileDiff(file, content) {
|
|
2536
|
+
const lines = content.split("\n");
|
|
2537
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2538
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2539
|
+
const header = [
|
|
2540
|
+
`diff --git a/${file} b/${file}`,
|
|
2541
|
+
"new file mode 100644",
|
|
2542
|
+
"--- /dev/null",
|
|
2543
|
+
`+++ b/${file}`,
|
|
2544
|
+
`@@ -0,0 +1,${bodyLines.length} @@`
|
|
2545
|
+
].join("\n");
|
|
2546
|
+
const body = bodyLines.map((l) => `+${l}`).join("\n");
|
|
2547
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2548
|
+
return header + "\n" + body + trailer + "\n";
|
|
2549
|
+
}
|
|
2550
|
+
function synthesizeDeleteFileDiff(file, content) {
|
|
2551
|
+
const lines = content.split("\n");
|
|
2552
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
2553
|
+
const bodyLines = hasTrailingNewline ? lines.slice(0, -1) : lines;
|
|
2554
|
+
const header = [
|
|
2555
|
+
`diff --git a/${file} b/${file}`,
|
|
2556
|
+
"deleted file mode 100644",
|
|
2557
|
+
`--- a/${file}`,
|
|
2558
|
+
"+++ /dev/null",
|
|
2559
|
+
`@@ -1,${bodyLines.length} +0,0 @@`
|
|
2560
|
+
].join("\n");
|
|
2561
|
+
const body = bodyLines.map((l) => `-${l}`).join("\n");
|
|
2562
|
+
const trailer = hasTrailingNewline ? "" : "\n\";
|
|
2563
|
+
return header + "\n" + body + trailer + "\n";
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// src/ReplayService.ts
|
|
2126
2567
|
var ReplayService = class {
|
|
2127
2568
|
git;
|
|
2128
2569
|
detector;
|
|
@@ -2516,6 +2957,7 @@ var ReplayService = class {
|
|
|
2516
2957
|
if (newPatches.length > 0) {
|
|
2517
2958
|
results = await this.applicator.applyPatches(newPatches);
|
|
2518
2959
|
this._lastApplyResults = results;
|
|
2960
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2519
2961
|
this.revertConflictingFiles(results);
|
|
2520
2962
|
for (const result of results) {
|
|
2521
2963
|
if (result.status === "conflict") {
|
|
@@ -2534,7 +2976,8 @@ var ReplayService = class {
|
|
|
2534
2976
|
if (newPatches.length > 0) {
|
|
2535
2977
|
if (!options?.stageOnly) {
|
|
2536
2978
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2537
|
-
|
|
2979
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
2980
|
+
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
2538
2981
|
} else {
|
|
2539
2982
|
await this.committer.stageAll();
|
|
2540
2983
|
}
|
|
@@ -2597,12 +3040,21 @@ var ReplayService = class {
|
|
|
2597
3040
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
2598
3041
|
);
|
|
2599
3042
|
existingPatches = this.lockManager.getPatches();
|
|
2600
|
-
const seenHashes = /* @__PURE__ */ new
|
|
3043
|
+
const seenHashes = /* @__PURE__ */ new Map();
|
|
2601
3044
|
for (const p of existingPatches) {
|
|
2602
|
-
|
|
2603
|
-
|
|
3045
|
+
const priorOrigin = seenHashes.get(p.content_hash);
|
|
3046
|
+
if (priorOrigin !== void 0) {
|
|
3047
|
+
const provenanceMissing = !priorOrigin || !p.original_commit;
|
|
3048
|
+
const sameOrigin = priorOrigin === p.original_commit;
|
|
3049
|
+
if (sameOrigin && !provenanceMissing) {
|
|
3050
|
+
this.lockManager.removePatch(p.id);
|
|
3051
|
+
} else {
|
|
3052
|
+
this.warnings.push(
|
|
3053
|
+
`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).`
|
|
3054
|
+
);
|
|
3055
|
+
}
|
|
2604
3056
|
} else {
|
|
2605
|
-
seenHashes.
|
|
3057
|
+
seenHashes.set(p.content_hash, p.original_commit);
|
|
2606
3058
|
}
|
|
2607
3059
|
}
|
|
2608
3060
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -2663,6 +3115,7 @@ var ReplayService = class {
|
|
|
2663
3115
|
const genSha = prep._prepared.genSha;
|
|
2664
3116
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2665
3117
|
this._lastApplyResults = results;
|
|
3118
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2666
3119
|
this.revertConflictingFiles(results);
|
|
2667
3120
|
for (const result of results) {
|
|
2668
3121
|
if (result.status === "conflict") {
|
|
@@ -2690,7 +3143,8 @@ var ReplayService = class {
|
|
|
2690
3143
|
await this.committer.stageAll();
|
|
2691
3144
|
} else {
|
|
2692
3145
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2693
|
-
|
|
3146
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
3147
|
+
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
2694
3148
|
}
|
|
2695
3149
|
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2696
3150
|
return this.buildReport(
|
|
@@ -2714,7 +3168,7 @@ var ReplayService = class {
|
|
|
2714
3168
|
let repointed = 0;
|
|
2715
3169
|
let contentRebased = 0;
|
|
2716
3170
|
let keptAsUserOwned = 0;
|
|
2717
|
-
const seenContentHashes = /* @__PURE__ */ new
|
|
3171
|
+
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
2718
3172
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2719
3173
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2720
3174
|
for (const result of results) {
|
|
@@ -2728,6 +3182,23 @@ var ReplayService = class {
|
|
|
2728
3182
|
}
|
|
2729
3183
|
}
|
|
2730
3184
|
}
|
|
3185
|
+
const conflictedFilePaths = /* @__PURE__ */ new Set();
|
|
3186
|
+
for (const r of results) {
|
|
3187
|
+
if (r.status !== "conflict") continue;
|
|
3188
|
+
if (r.fileResults) {
|
|
3189
|
+
for (const fr of r.fileResults) {
|
|
3190
|
+
if (fr.status !== "conflict") continue;
|
|
3191
|
+
conflictedFilePaths.add(fr.file);
|
|
3192
|
+
if (r.resolvedFiles) {
|
|
3193
|
+
for (const [orig, resolved] of Object.entries(r.resolvedFiles)) {
|
|
3194
|
+
if (resolved === fr.file) conflictedFilePaths.add(orig);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
} else {
|
|
3199
|
+
for (const f of r.patch.files) conflictedFilePaths.add(f);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
2731
3202
|
for (const result of results) {
|
|
2732
3203
|
if (result.status === "conflict" && result.fileResults) {
|
|
2733
3204
|
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
@@ -2795,6 +3266,18 @@ var ReplayService = class {
|
|
|
2795
3266
|
keptAsUserOwned++;
|
|
2796
3267
|
continue;
|
|
2797
3268
|
}
|
|
3269
|
+
const overlapsConflicted = patch.files.some((f) => conflictedFilePaths.has(f));
|
|
3270
|
+
if (overlapsConflicted) {
|
|
3271
|
+
patch.status = "unresolved";
|
|
3272
|
+
try {
|
|
3273
|
+
this.lockManager.updatePatch(patch.id, { status: "unresolved" });
|
|
3274
|
+
} catch {
|
|
3275
|
+
}
|
|
3276
|
+
this.warnings.push(
|
|
3277
|
+
`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.`
|
|
3278
|
+
);
|
|
3279
|
+
continue;
|
|
3280
|
+
}
|
|
2798
3281
|
this.lockManager.removePatch(patch.id);
|
|
2799
3282
|
absorbedPatchIds.add(patch.id);
|
|
2800
3283
|
absorbed++;
|
|
@@ -2810,13 +3293,22 @@ var ReplayService = class {
|
|
|
2810
3293
|
continue;
|
|
2811
3294
|
}
|
|
2812
3295
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
3296
|
+
const priorOrigin = seenContentHashes.get(newContentHash);
|
|
3297
|
+
if (priorOrigin !== void 0) {
|
|
3298
|
+
const provenanceMissing = !priorOrigin || !patch.original_commit;
|
|
3299
|
+
const sameOrigin = priorOrigin === patch.original_commit;
|
|
3300
|
+
if (sameOrigin && !provenanceMissing) {
|
|
3301
|
+
this.lockManager.removePatch(patch.id);
|
|
3302
|
+
absorbedPatchIds.add(patch.id);
|
|
3303
|
+
absorbed++;
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
3306
|
+
this.warnings.push(
|
|
3307
|
+
`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).`
|
|
3308
|
+
);
|
|
3309
|
+
} else {
|
|
3310
|
+
seenContentHashes.set(newContentHash, patch.original_commit);
|
|
2818
3311
|
}
|
|
2819
|
-
seenContentHashes.add(newContentHash);
|
|
2820
3312
|
patch.base_generation = currentGenSha;
|
|
2821
3313
|
patch.patch_content = diff;
|
|
2822
3314
|
patch.content_hash = newContentHash;
|
|
@@ -2992,8 +3484,14 @@ var ReplayService = class {
|
|
|
2992
3484
|
}
|
|
2993
3485
|
if (patch.base_generation === currentGen) {
|
|
2994
3486
|
try {
|
|
2995
|
-
const
|
|
2996
|
-
|
|
3487
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3488
|
+
patch,
|
|
3489
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3490
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3491
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3492
|
+
);
|
|
3493
|
+
if (ppResult === null) continue;
|
|
3494
|
+
const diff = ppResult.diff;
|
|
2997
3495
|
if (!diff.trim()) {
|
|
2998
3496
|
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2999
3497
|
this.lockManager.removePatch(patch.id);
|
|
@@ -3035,8 +3533,14 @@ var ReplayService = class {
|
|
|
3035
3533
|
try {
|
|
3036
3534
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
3037
3535
|
if (markerFiles.trim()) continue;
|
|
3038
|
-
const
|
|
3039
|
-
|
|
3536
|
+
const ppResult = await computePerPatchDiffWithFallback(
|
|
3537
|
+
patch,
|
|
3538
|
+
(f) => this.git.showFile(currentGen, f),
|
|
3539
|
+
(f) => this.git.showFile("HEAD", f),
|
|
3540
|
+
(files) => this.git.exec(["diff", currentGen, "HEAD", "--", ...files]).catch(() => null)
|
|
3541
|
+
);
|
|
3542
|
+
if (ppResult === null) continue;
|
|
3543
|
+
const diff = ppResult.diff;
|
|
3040
3544
|
if (!diff.trim()) {
|
|
3041
3545
|
if (await allPatchFilesUserOwned(patch)) {
|
|
3042
3546
|
try {
|
|
@@ -3061,6 +3565,32 @@ var ReplayService = class {
|
|
|
3061
3565
|
}
|
|
3062
3566
|
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
3063
3567
|
}
|
|
3568
|
+
/**
|
|
3569
|
+
* After applyPatches(), surface a warning for each (patch × file) where
|
|
3570
|
+
* diff3 silently dropped lines that were unchanged context in the
|
|
3571
|
+
* customer's THEIRS. The deletion stays on disk (correct diff3 outcome),
|
|
3572
|
+
* but the customer must learn so they can re-add the lines if they
|
|
3573
|
+
* relied on them. This is the "surface or preserve, never silently drop"
|
|
3574
|
+
* contract for legitimate-deletion cases.
|
|
3575
|
+
*/
|
|
3576
|
+
recordDroppedContextLineWarnings(results) {
|
|
3577
|
+
const PREVIEW_COUNT = 5;
|
|
3578
|
+
for (const result of results) {
|
|
3579
|
+
if (!result.fileResults) continue;
|
|
3580
|
+
for (const fr of result.fileResults) {
|
|
3581
|
+
const dropped = fr.droppedContextLines;
|
|
3582
|
+
if (!dropped || dropped.length === 0) continue;
|
|
3583
|
+
const preview = dropped.slice(0, PREVIEW_COUNT).map((line) => ` ${line}`).join("\n");
|
|
3584
|
+
const more = dropped.length > PREVIEW_COUNT ? `
|
|
3585
|
+
... and ${dropped.length - PREVIEW_COUNT} more` : "";
|
|
3586
|
+
this.warnings.push(
|
|
3587
|
+
`${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:
|
|
3588
|
+
${preview}${more}
|
|
3589
|
+
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.`
|
|
3590
|
+
);
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3064
3594
|
/**
|
|
3065
3595
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
3066
3596
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -3070,7 +3600,7 @@ var ReplayService = class {
|
|
|
3070
3600
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3071
3601
|
for (const fileResult of result.fileResults) {
|
|
3072
3602
|
if (fileResult.status !== "conflict") continue;
|
|
3073
|
-
const filePath =
|
|
3603
|
+
const filePath = join4(this.outputDir, fileResult.file);
|
|
3074
3604
|
try {
|
|
3075
3605
|
const content = readFileSync2(filePath, "utf-8");
|
|
3076
3606
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3100,7 +3630,7 @@ var ReplayService = class {
|
|
|
3100
3630
|
}
|
|
3101
3631
|
}
|
|
3102
3632
|
readFernignorePatterns() {
|
|
3103
|
-
const fernignorePath =
|
|
3633
|
+
const fernignorePath = join4(this.outputDir, ".fernignore");
|
|
3104
3634
|
if (!existsSync2(fernignorePath)) return [];
|
|
3105
3635
|
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3106
3636
|
}
|
|
@@ -3118,6 +3648,9 @@ var ReplayService = class {
|
|
|
3118
3648
|
};
|
|
3119
3649
|
}).filter((d) => d.files.length > 0);
|
|
3120
3650
|
const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
|
|
3651
|
+
const unresolvedResults = results.filter(
|
|
3652
|
+
(r) => r.status === "conflict" || r.patch.status === "unresolved"
|
|
3653
|
+
);
|
|
3121
3654
|
return {
|
|
3122
3655
|
flow,
|
|
3123
3656
|
patchesDetected: patches.length,
|
|
@@ -3134,7 +3667,7 @@ var ReplayService = class {
|
|
|
3134
3667
|
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
3135
3668
|
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
3136
3669
|
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
3137
|
-
unresolvedPatches:
|
|
3670
|
+
unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
|
|
3138
3671
|
patchId: r.patch.id,
|
|
3139
3672
|
patchMessage: r.patch.original_message,
|
|
3140
3673
|
files: r.patch.files,
|
|
@@ -3145,11 +3678,38 @@ var ReplayService = class {
|
|
|
3145
3678
|
};
|
|
3146
3679
|
}
|
|
3147
3680
|
};
|
|
3681
|
+
function computeCommitBuckets(results, absorbedPatchIds, patchesPassedToCommit) {
|
|
3682
|
+
const resultByPatchId = /* @__PURE__ */ new Map();
|
|
3683
|
+
for (const r of results) resultByPatchId.set(r.patch.id, r);
|
|
3684
|
+
const applied = [];
|
|
3685
|
+
const unresolved = [];
|
|
3686
|
+
const absorbed = [];
|
|
3687
|
+
for (const patch of patchesPassedToCommit) {
|
|
3688
|
+
if (absorbedPatchIds.has(patch.id)) {
|
|
3689
|
+
absorbed.push(patch);
|
|
3690
|
+
continue;
|
|
3691
|
+
}
|
|
3692
|
+
const r = resultByPatchId.get(patch.id);
|
|
3693
|
+
if (!r) {
|
|
3694
|
+
applied.push(patch);
|
|
3695
|
+
continue;
|
|
3696
|
+
}
|
|
3697
|
+
if (r.status === "conflict" || patch.status === "unresolved") {
|
|
3698
|
+
unresolved.push(patch);
|
|
3699
|
+
continue;
|
|
3700
|
+
}
|
|
3701
|
+
if (r.status === "applied") {
|
|
3702
|
+
applied.push(patch);
|
|
3703
|
+
continue;
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
return { applied, unresolved, absorbed };
|
|
3707
|
+
}
|
|
3148
3708
|
|
|
3149
3709
|
// src/FernignoreMigrator.ts
|
|
3150
3710
|
import { createHash as createHash2 } from "crypto";
|
|
3151
3711
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
3152
|
-
import { dirname as dirname3, join as
|
|
3712
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
3153
3713
|
import { minimatch as minimatch3 } from "minimatch";
|
|
3154
3714
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
3155
3715
|
var FernignoreMigrator = class {
|
|
@@ -3162,10 +3722,10 @@ var FernignoreMigrator = class {
|
|
|
3162
3722
|
this.outputDir = outputDir;
|
|
3163
3723
|
}
|
|
3164
3724
|
fernignoreExists() {
|
|
3165
|
-
return existsSync3(
|
|
3725
|
+
return existsSync3(join5(this.outputDir, ".fernignore"));
|
|
3166
3726
|
}
|
|
3167
3727
|
readFernignorePatterns() {
|
|
3168
|
-
const fernignorePath =
|
|
3728
|
+
const fernignorePath = join5(this.outputDir, ".fernignore");
|
|
3169
3729
|
if (!existsSync3(fernignorePath)) {
|
|
3170
3730
|
return [];
|
|
3171
3731
|
}
|
|
@@ -3225,7 +3785,16 @@ var FernignoreMigrator = class {
|
|
|
3225
3785
|
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
3226
3786
|
base_generation: currentGen.commit_sha,
|
|
3227
3787
|
files: patchFiles,
|
|
3228
|
-
patch_content: patchContent
|
|
3788
|
+
patch_content: patchContent,
|
|
3789
|
+
// Synthetic patches captured from .fernignore-protected files
|
|
3790
|
+
// are user-owned by definition — the customer's content
|
|
3791
|
+
// diverges from the generator's pristine output, and the
|
|
3792
|
+
// generator never produced this divergent content. Without
|
|
3793
|
+
// this flag, removing the file from .fernignore later (so
|
|
3794
|
+
// it leaves the protection check in `isFileUserOwned`) would
|
|
3795
|
+
// expose the patch to absorption on the next regen,
|
|
3796
|
+
// silently losing the customization.
|
|
3797
|
+
user_owned: true
|
|
3229
3798
|
});
|
|
3230
3799
|
trackedByBoth.push({
|
|
3231
3800
|
file: pattern,
|
|
@@ -3258,7 +3827,7 @@ var FernignoreMigrator = class {
|
|
|
3258
3827
|
return results;
|
|
3259
3828
|
}
|
|
3260
3829
|
readFileContent(filePath) {
|
|
3261
|
-
const fullPath =
|
|
3830
|
+
const fullPath = join5(this.outputDir, filePath);
|
|
3262
3831
|
if (!existsSync3(fullPath)) {
|
|
3263
3832
|
return null;
|
|
3264
3833
|
}
|
|
@@ -3323,7 +3892,7 @@ var FernignoreMigrator = class {
|
|
|
3323
3892
|
};
|
|
3324
3893
|
}
|
|
3325
3894
|
movePatternsToReplayYml(patterns) {
|
|
3326
|
-
const replayYmlPath =
|
|
3895
|
+
const replayYmlPath = join5(this.outputDir, ".fern", "replay.yml");
|
|
3327
3896
|
let config = {};
|
|
3328
3897
|
if (existsSync3(replayYmlPath)) {
|
|
3329
3898
|
const content = readFileSync3(replayYmlPath, "utf-8");
|
|
@@ -3343,7 +3912,7 @@ var FernignoreMigrator = class {
|
|
|
3343
3912
|
// src/commands/bootstrap.ts
|
|
3344
3913
|
import { createHash as createHash3 } from "crypto";
|
|
3345
3914
|
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
3346
|
-
import { join as
|
|
3915
|
+
import { join as join6 } from "path";
|
|
3347
3916
|
init_GitClient();
|
|
3348
3917
|
async function bootstrap(outputDir, options) {
|
|
3349
3918
|
const git = new GitClient(outputDir);
|
|
@@ -3510,7 +4079,7 @@ function parseGitLog(log) {
|
|
|
3510
4079
|
}
|
|
3511
4080
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
3512
4081
|
function ensureFernignoreEntries(outputDir) {
|
|
3513
|
-
const fernignorePath =
|
|
4082
|
+
const fernignorePath = join6(outputDir, ".fernignore");
|
|
3514
4083
|
let content = "";
|
|
3515
4084
|
if (existsSync4(fernignorePath)) {
|
|
3516
4085
|
content = readFileSync4(fernignorePath, "utf-8");
|
|
@@ -3534,7 +4103,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
3534
4103
|
}
|
|
3535
4104
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
3536
4105
|
function ensureGitattributesEntries(outputDir) {
|
|
3537
|
-
const gitattributesPath =
|
|
4106
|
+
const gitattributesPath = join6(outputDir, ".gitattributes");
|
|
3538
4107
|
let content = "";
|
|
3539
4108
|
if (existsSync4(gitattributesPath)) {
|
|
3540
4109
|
content = readFileSync4(gitattributesPath, "utf-8");
|
|
@@ -3733,6 +4302,8 @@ function reset(outputDir, options) {
|
|
|
3733
4302
|
|
|
3734
4303
|
// src/commands/resolve.ts
|
|
3735
4304
|
init_GitClient();
|
|
4305
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
4306
|
+
import { join as join7 } from "path";
|
|
3736
4307
|
async function resolve(outputDir, options) {
|
|
3737
4308
|
const lockManager = new LockfileManager(outputDir);
|
|
3738
4309
|
if (!lockManager.exists()) {
|
|
@@ -3747,7 +4318,7 @@ async function resolve(outputDir, options) {
|
|
|
3747
4318
|
const resolvingPatches = lockManager.getResolvingPatches();
|
|
3748
4319
|
if (unresolvedPatches.length > 0) {
|
|
3749
4320
|
const applicator = new ReplayApplicator(git, lockManager, outputDir);
|
|
3750
|
-
await applicator.applyPatches(unresolvedPatches);
|
|
4321
|
+
await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
|
|
3751
4322
|
const markerFiles = await findConflictMarkerFiles(git);
|
|
3752
4323
|
if (markerFiles.length > 0) {
|
|
3753
4324
|
for (const patch of unresolvedPatches) {
|
|
@@ -3773,20 +4344,38 @@ async function resolve(outputDir, options) {
|
|
|
3773
4344
|
if (patchesToCommit.length > 0) {
|
|
3774
4345
|
const currentGen = lock.current_generation;
|
|
3775
4346
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4347
|
+
const warnings = [];
|
|
3776
4348
|
let patchesResolved = 0;
|
|
3777
4349
|
for (const patch of patchesToCommit) {
|
|
3778
|
-
const
|
|
3779
|
-
|
|
4350
|
+
const result = await computePerPatchDiffWithFallback(
|
|
4351
|
+
patch,
|
|
4352
|
+
(f) => git.showFile(currentGen, f),
|
|
4353
|
+
(f) => readFile2(join7(outputDir, f), "utf-8").catch(() => null),
|
|
4354
|
+
(files) => git.exec(["diff", currentGen, "--", ...files]).catch(() => null)
|
|
4355
|
+
);
|
|
4356
|
+
if (result === null) {
|
|
4357
|
+
warnings.push(
|
|
4358
|
+
`Patch ${patch.id}: cumulative-diff fallback failed for unlocatable files; preserving patch unchanged`
|
|
4359
|
+
);
|
|
4360
|
+
continue;
|
|
4361
|
+
}
|
|
4362
|
+
const diff = result.diff;
|
|
4363
|
+
if (!diff.trim()) {
|
|
3780
4364
|
lockManager.removePatch(patch.id);
|
|
3781
4365
|
continue;
|
|
3782
4366
|
}
|
|
4367
|
+
if (result.hadFallback) {
|
|
4368
|
+
warnings.push(
|
|
4369
|
+
`Patch ${patch.id}: ${result.fallbackFiles.join(", ")} could not be anchored; cumulative-diff fallback applied for those files`
|
|
4370
|
+
);
|
|
4371
|
+
}
|
|
3783
4372
|
const newContentHash = detector.computeContentHash(diff);
|
|
3784
|
-
const changedFiles = await getChangedFiles(git, currentGen, patch.files);
|
|
4373
|
+
const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
|
|
3785
4374
|
lockManager.markPatchResolved(patch.id, {
|
|
3786
4375
|
patch_content: diff,
|
|
3787
4376
|
content_hash: newContentHash,
|
|
3788
4377
|
base_generation: currentGen,
|
|
3789
|
-
files: changedFiles
|
|
4378
|
+
files: changedFiles.length > 0 ? changedFiles : patch.files
|
|
3790
4379
|
});
|
|
3791
4380
|
patchesResolved++;
|
|
3792
4381
|
}
|
|
@@ -3802,7 +4391,8 @@ async function resolve(outputDir, options) {
|
|
|
3802
4391
|
success: true,
|
|
3803
4392
|
commitSha: commitSha2,
|
|
3804
4393
|
phase: "committed",
|
|
3805
|
-
patchesResolved
|
|
4394
|
+
patchesResolved,
|
|
4395
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
3806
4396
|
};
|
|
3807
4397
|
}
|
|
3808
4398
|
const committer = new ReplayCommitter(git, outputDir);
|
|
@@ -3820,6 +4410,24 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
3820
4410
|
const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
|
|
3821
4411
|
return changed.length > 0 ? changed : files;
|
|
3822
4412
|
}
|
|
4413
|
+
function extractFilesFromDiff(unifiedDiff2) {
|
|
4414
|
+
const out = /* @__PURE__ */ new Set();
|
|
4415
|
+
const renameSources = /* @__PURE__ */ new Set();
|
|
4416
|
+
let currentTarget = null;
|
|
4417
|
+
for (const line of unifiedDiff2.split("\n")) {
|
|
4418
|
+
const headerMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
4419
|
+
if (headerMatch) {
|
|
4420
|
+
currentTarget = headerMatch[2];
|
|
4421
|
+
if (!currentTarget.startsWith(".fern/")) out.add(currentTarget);
|
|
4422
|
+
continue;
|
|
4423
|
+
}
|
|
4424
|
+
if (currentTarget && line.startsWith("rename from ")) {
|
|
4425
|
+
renameSources.add(line.slice("rename from ".length));
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
for (const src of renameSources) out.delete(src);
|
|
4429
|
+
return Array.from(out);
|
|
4430
|
+
}
|
|
3823
4431
|
|
|
3824
4432
|
// src/commands/status.ts
|
|
3825
4433
|
function status(outputDir) {
|