@fern-api/replay 0.12.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 +707 -61
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +705 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +97 -3
- package/dist/index.d.ts +97 -3
- package/dist/index.js +704 -56
- 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) => {
|
|
@@ -762,17 +762,57 @@ var ReplayDetector = class {
|
|
|
762
762
|
}
|
|
763
763
|
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
764
764
|
if (!isAncestor) {
|
|
765
|
+
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
766
|
+
}
|
|
767
|
+
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
|
|
771
|
+
*
|
|
772
|
+
* After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
|
|
773
|
+
* `[fern-generated]`) is orphaned but still in git's object database. The
|
|
774
|
+
* merge-base is the commit on main from which the bot's branch was created —
|
|
775
|
+
* a reachable ancestor of HEAD. Walking that range and classifying each
|
|
776
|
+
* commit via `isGenerationCommit` mirrors the linear path and eliminates
|
|
777
|
+
* the bug class where pipeline-driven changes (autoversion, replay,
|
|
778
|
+
* generation) get bundled as customer customizations.
|
|
779
|
+
*
|
|
780
|
+
* Falls through to the legacy composite path when no common ancestor exists
|
|
781
|
+
* (truly disjoint history — orphan branches with no shared root).
|
|
782
|
+
*/
|
|
783
|
+
async detectPatchesViaMergeBase(lastGen, lock) {
|
|
784
|
+
let mergeBase = "";
|
|
785
|
+
try {
|
|
786
|
+
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
787
|
+
} catch {
|
|
788
|
+
}
|
|
789
|
+
if (!mergeBase) {
|
|
790
|
+
this.warnings.push(
|
|
791
|
+
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
792
|
+
);
|
|
765
793
|
return this.detectPatchesViaTreeDiff(
|
|
766
794
|
lastGen,
|
|
767
795
|
/* commitKnownMissing */
|
|
768
796
|
false
|
|
769
797
|
);
|
|
770
798
|
}
|
|
799
|
+
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
|
|
803
|
+
* `StoredPatch` per customer commit. Body shared by the linear path
|
|
804
|
+
* (rangeStart = lastGen.commit_sha) and the merge-base path.
|
|
805
|
+
*
|
|
806
|
+
* Patch `base_generation` is always `lastGen.commit_sha` — the
|
|
807
|
+
* lockfile-tracked reference the applicator uses to fetch base file
|
|
808
|
+
* content, regardless of where the walk actually started.
|
|
809
|
+
*/
|
|
810
|
+
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
771
811
|
const log = await this.git.exec([
|
|
772
812
|
"log",
|
|
773
813
|
"--first-parent",
|
|
774
814
|
"--format=%H%x00%an%x00%ae%x00%s",
|
|
775
|
-
`${
|
|
815
|
+
`${rangeStart}..HEAD`,
|
|
776
816
|
"--",
|
|
777
817
|
this.sdkOutputDir
|
|
778
818
|
]);
|
|
@@ -1272,11 +1312,43 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
1272
1312
|
}
|
|
1273
1313
|
}
|
|
1274
1314
|
}
|
|
1275
|
-
|
|
1315
|
+
const result = {
|
|
1276
1316
|
content: outputLines.join("\n"),
|
|
1277
1317
|
hasConflicts: conflicts.length > 0,
|
|
1278
1318
|
conflicts
|
|
1279
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;
|
|
1280
1352
|
}
|
|
1281
1353
|
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1282
1354
|
if (baseLines.length === 0) {
|
|
@@ -1471,6 +1543,19 @@ var ReplayApplicator = class {
|
|
|
1471
1543
|
renameCache = /* @__PURE__ */ new Map();
|
|
1472
1544
|
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1473
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";
|
|
1474
1559
|
constructor(git, lockManager, outputDir) {
|
|
1475
1560
|
this.git = git;
|
|
1476
1561
|
this.lockManager = lockManager;
|
|
@@ -1518,8 +1603,23 @@ var ReplayApplicator = class {
|
|
|
1518
1603
|
/**
|
|
1519
1604
|
* Apply all patches, returning results for each.
|
|
1520
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.
|
|
1521
1619
|
*/
|
|
1522
|
-
async applyPatches(patches) {
|
|
1620
|
+
async applyPatches(patches, opts) {
|
|
1621
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
1622
|
+
this.currentApplyMode = applyMode;
|
|
1523
1623
|
this.resetAccumulator();
|
|
1524
1624
|
const results = [];
|
|
1525
1625
|
for (let i = 0; i < patches.length; i++) {
|
|
@@ -1534,7 +1634,7 @@ var ReplayApplicator = class {
|
|
|
1534
1634
|
}
|
|
1535
1635
|
const result = await this.applyPatchWithFallback(patch);
|
|
1536
1636
|
results.push(result);
|
|
1537
|
-
if (result.status === "conflict" && result.fileResults) {
|
|
1637
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
1538
1638
|
const laterFiles = /* @__PURE__ */ new Set();
|
|
1539
1639
|
for (let j = i + 1; j < patches.length; j++) {
|
|
1540
1640
|
for (const f of patches[j].files) {
|
|
@@ -1564,9 +1664,17 @@ var ReplayApplicator = class {
|
|
|
1564
1664
|
}
|
|
1565
1665
|
return results;
|
|
1566
1666
|
}
|
|
1567
|
-
/**
|
|
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
|
+
*/
|
|
1568
1675
|
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
1569
|
-
|
|
1676
|
+
const fileResults = [];
|
|
1677
|
+
if (!baseGen) return fileResults;
|
|
1570
1678
|
const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
|
|
1571
1679
|
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1572
1680
|
const tempGit = new GitClient2(tempDir);
|
|
@@ -1579,18 +1687,30 @@ var ReplayApplicator = class {
|
|
|
1579
1687
|
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1580
1688
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1581
1689
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1582
|
-
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;
|
|
1583
1692
|
if (effectiveTheirs != null) {
|
|
1584
1693
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1585
1694
|
content: effectiveTheirs,
|
|
1586
1695
|
baseGeneration: patch.base_generation
|
|
1587
1696
|
});
|
|
1588
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
|
+
}
|
|
1589
1708
|
}
|
|
1590
1709
|
} finally {
|
|
1591
1710
|
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
1592
1711
|
});
|
|
1593
1712
|
}
|
|
1713
|
+
return fileResults;
|
|
1594
1714
|
}
|
|
1595
1715
|
async applyPatchWithFallback(patch) {
|
|
1596
1716
|
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
@@ -1621,11 +1741,12 @@ var ReplayApplicator = class {
|
|
|
1621
1741
|
}
|
|
1622
1742
|
try {
|
|
1623
1743
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
1624
|
-
await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1744
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1625
1745
|
return {
|
|
1626
1746
|
patch,
|
|
1627
1747
|
status: "applied",
|
|
1628
1748
|
method: "git-am",
|
|
1749
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1629
1750
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1630
1751
|
};
|
|
1631
1752
|
} catch {
|
|
@@ -1737,19 +1858,23 @@ var ReplayApplicator = class {
|
|
|
1737
1858
|
renameSourcePath
|
|
1738
1859
|
);
|
|
1739
1860
|
}
|
|
1861
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1740
1862
|
if (theirs) {
|
|
1741
1863
|
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1742
1864
|
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1743
1865
|
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1866
|
+
if (accumulatorEntry) {
|
|
1867
|
+
theirs = null;
|
|
1868
|
+
} else {
|
|
1869
|
+
return {
|
|
1870
|
+
file: resolvedPath,
|
|
1871
|
+
status: "skipped",
|
|
1872
|
+
reason: "stale-conflict-markers"
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1749
1875
|
}
|
|
1750
1876
|
}
|
|
1751
1877
|
let useAccumulatorAsMergeBase = false;
|
|
1752
|
-
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1753
1878
|
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1754
1879
|
theirs = await this.applyPatchToContent(
|
|
1755
1880
|
accumulatorEntry.content,
|
|
@@ -1856,7 +1981,8 @@ var ReplayApplicator = class {
|
|
|
1856
1981
|
const outDir = (0, import_node_path2.dirname)(oursPath);
|
|
1857
1982
|
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
1858
1983
|
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
1859
|
-
|
|
1984
|
+
const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
|
|
1985
|
+
if (populateAccumulator) {
|
|
1860
1986
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1861
1987
|
content: effective_theirs,
|
|
1862
1988
|
baseGeneration: patch.base_generation
|
|
@@ -1871,7 +1997,11 @@ var ReplayApplicator = class {
|
|
|
1871
1997
|
conflictMetadata: metadata
|
|
1872
1998
|
};
|
|
1873
1999
|
}
|
|
1874
|
-
return {
|
|
2000
|
+
return {
|
|
2001
|
+
file: resolvedPath,
|
|
2002
|
+
status: "merged",
|
|
2003
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
2004
|
+
};
|
|
1875
2005
|
} catch (error) {
|
|
1876
2006
|
return {
|
|
1877
2007
|
file: filePath,
|
|
@@ -2057,6 +2187,30 @@ function isBinaryFile(filePath) {
|
|
|
2057
2187
|
const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
|
|
2058
2188
|
return BINARY_EXTENSIONS.has(ext);
|
|
2059
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
|
+
}
|
|
2060
2214
|
function isDiffLineForFile(diffLine, filePath) {
|
|
2061
2215
|
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
2062
2216
|
return match !== null && match[1] === filePath;
|
|
@@ -2092,13 +2246,46 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2092
2246
|
await this.git.exec(["commit", "-m", fullMessage]);
|
|
2093
2247
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2094
2248
|
}
|
|
2095
|
-
async commitReplay(_patchCount, patches, message) {
|
|
2249
|
+
async commitReplay(_patchCount, patches, message, options) {
|
|
2096
2250
|
await this.stageAll();
|
|
2097
2251
|
if (!await this.hasStagedChanges()) {
|
|
2098
2252
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2099
2253
|
}
|
|
2100
2254
|
let fullMessage = message ?? `[fern-replay] Applied customizations`;
|
|
2101
|
-
|
|
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) {
|
|
2102
2289
|
fullMessage += "\n\nPatches replayed:";
|
|
2103
2290
|
for (const patch of patches) {
|
|
2104
2291
|
fullMessage += `
|
|
@@ -2134,9 +2321,303 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2134
2321
|
|
|
2135
2322
|
// src/ReplayService.ts
|
|
2136
2323
|
var import_node_fs2 = require("fs");
|
|
2137
|
-
var
|
|
2324
|
+
var import_node_path4 = require("path");
|
|
2138
2325
|
var import_minimatch2 = require("minimatch");
|
|
2139
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
|
|
2140
2621
|
var ReplayService = class {
|
|
2141
2622
|
git;
|
|
2142
2623
|
detector;
|
|
@@ -2530,6 +3011,7 @@ var ReplayService = class {
|
|
|
2530
3011
|
if (newPatches.length > 0) {
|
|
2531
3012
|
results = await this.applicator.applyPatches(newPatches);
|
|
2532
3013
|
this._lastApplyResults = results;
|
|
3014
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2533
3015
|
this.revertConflictingFiles(results);
|
|
2534
3016
|
for (const result of results) {
|
|
2535
3017
|
if (result.status === "conflict") {
|
|
@@ -2548,7 +3030,8 @@ var ReplayService = class {
|
|
|
2548
3030
|
if (newPatches.length > 0) {
|
|
2549
3031
|
if (!options?.stageOnly) {
|
|
2550
3032
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2551
|
-
|
|
3033
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
3034
|
+
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
2552
3035
|
} else {
|
|
2553
3036
|
await this.committer.stageAll();
|
|
2554
3037
|
}
|
|
@@ -2611,12 +3094,21 @@ var ReplayService = class {
|
|
|
2611
3094
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
2612
3095
|
);
|
|
2613
3096
|
existingPatches = this.lockManager.getPatches();
|
|
2614
|
-
const seenHashes = /* @__PURE__ */ new
|
|
3097
|
+
const seenHashes = /* @__PURE__ */ new Map();
|
|
2615
3098
|
for (const p of existingPatches) {
|
|
2616
|
-
|
|
2617
|
-
|
|
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
|
+
}
|
|
2618
3110
|
} else {
|
|
2619
|
-
seenHashes.
|
|
3111
|
+
seenHashes.set(p.content_hash, p.original_commit);
|
|
2620
3112
|
}
|
|
2621
3113
|
}
|
|
2622
3114
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -2677,6 +3169,7 @@ var ReplayService = class {
|
|
|
2677
3169
|
const genSha = prep._prepared.genSha;
|
|
2678
3170
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2679
3171
|
this._lastApplyResults = results;
|
|
3172
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2680
3173
|
this.revertConflictingFiles(results);
|
|
2681
3174
|
for (const result of results) {
|
|
2682
3175
|
if (result.status === "conflict") {
|
|
@@ -2704,7 +3197,8 @@ var ReplayService = class {
|
|
|
2704
3197
|
await this.committer.stageAll();
|
|
2705
3198
|
} else {
|
|
2706
3199
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2707
|
-
|
|
3200
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
3201
|
+
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
2708
3202
|
}
|
|
2709
3203
|
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2710
3204
|
return this.buildReport(
|
|
@@ -2728,7 +3222,7 @@ var ReplayService = class {
|
|
|
2728
3222
|
let repointed = 0;
|
|
2729
3223
|
let contentRebased = 0;
|
|
2730
3224
|
let keptAsUserOwned = 0;
|
|
2731
|
-
const seenContentHashes = /* @__PURE__ */ new
|
|
3225
|
+
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
2732
3226
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2733
3227
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2734
3228
|
for (const result of results) {
|
|
@@ -2742,6 +3236,23 @@ var ReplayService = class {
|
|
|
2742
3236
|
}
|
|
2743
3237
|
}
|
|
2744
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
|
+
}
|
|
2745
3256
|
for (const result of results) {
|
|
2746
3257
|
if (result.status === "conflict" && result.fileResults) {
|
|
2747
3258
|
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
@@ -2809,6 +3320,18 @@ var ReplayService = class {
|
|
|
2809
3320
|
keptAsUserOwned++;
|
|
2810
3321
|
continue;
|
|
2811
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
|
+
}
|
|
2812
3335
|
this.lockManager.removePatch(patch.id);
|
|
2813
3336
|
absorbedPatchIds.add(patch.id);
|
|
2814
3337
|
absorbed++;
|
|
@@ -2824,13 +3347,22 @@ var ReplayService = class {
|
|
|
2824
3347
|
continue;
|
|
2825
3348
|
}
|
|
2826
3349
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
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);
|
|
2832
3365
|
}
|
|
2833
|
-
seenContentHashes.add(newContentHash);
|
|
2834
3366
|
patch.base_generation = currentGenSha;
|
|
2835
3367
|
patch.patch_content = diff;
|
|
2836
3368
|
patch.content_hash = newContentHash;
|
|
@@ -3006,8 +3538,14 @@ var ReplayService = class {
|
|
|
3006
3538
|
}
|
|
3007
3539
|
if (patch.base_generation === currentGen) {
|
|
3008
3540
|
try {
|
|
3009
|
-
const
|
|
3010
|
-
|
|
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;
|
|
3011
3549
|
if (!diff.trim()) {
|
|
3012
3550
|
if (await allPatchFilesUserOwned(patch)) continue;
|
|
3013
3551
|
this.lockManager.removePatch(patch.id);
|
|
@@ -3049,8 +3587,14 @@ var ReplayService = class {
|
|
|
3049
3587
|
try {
|
|
3050
3588
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
3051
3589
|
if (markerFiles.trim()) continue;
|
|
3052
|
-
const
|
|
3053
|
-
|
|
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;
|
|
3054
3598
|
if (!diff.trim()) {
|
|
3055
3599
|
if (await allPatchFilesUserOwned(patch)) {
|
|
3056
3600
|
try {
|
|
@@ -3075,6 +3619,32 @@ var ReplayService = class {
|
|
|
3075
3619
|
}
|
|
3076
3620
|
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
3077
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
|
+
}
|
|
3078
3648
|
/**
|
|
3079
3649
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
3080
3650
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -3084,7 +3654,7 @@ var ReplayService = class {
|
|
|
3084
3654
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3085
3655
|
for (const fileResult of result.fileResults) {
|
|
3086
3656
|
if (fileResult.status !== "conflict") continue;
|
|
3087
|
-
const filePath = (0,
|
|
3657
|
+
const filePath = (0, import_node_path4.join)(this.outputDir, fileResult.file);
|
|
3088
3658
|
try {
|
|
3089
3659
|
const content = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
|
|
3090
3660
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3114,7 +3684,7 @@ var ReplayService = class {
|
|
|
3114
3684
|
}
|
|
3115
3685
|
}
|
|
3116
3686
|
readFernignorePatterns() {
|
|
3117
|
-
const fernignorePath = (0,
|
|
3687
|
+
const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
|
|
3118
3688
|
if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
|
|
3119
3689
|
return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3120
3690
|
}
|
|
@@ -3132,6 +3702,9 @@ var ReplayService = class {
|
|
|
3132
3702
|
};
|
|
3133
3703
|
}).filter((d) => d.files.length > 0);
|
|
3134
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
|
+
);
|
|
3135
3708
|
return {
|
|
3136
3709
|
flow,
|
|
3137
3710
|
patchesDetected: patches.length,
|
|
@@ -3148,7 +3721,7 @@ var ReplayService = class {
|
|
|
3148
3721
|
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
3149
3722
|
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
3150
3723
|
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
3151
|
-
unresolvedPatches:
|
|
3724
|
+
unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
|
|
3152
3725
|
patchId: r.patch.id,
|
|
3153
3726
|
patchMessage: r.patch.original_message,
|
|
3154
3727
|
files: r.patch.files,
|
|
@@ -3159,11 +3732,38 @@ var ReplayService = class {
|
|
|
3159
3732
|
};
|
|
3160
3733
|
}
|
|
3161
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
|
+
}
|
|
3162
3762
|
|
|
3163
3763
|
// src/FernignoreMigrator.ts
|
|
3164
3764
|
var import_node_crypto2 = require("crypto");
|
|
3165
3765
|
var import_node_fs3 = require("fs");
|
|
3166
|
-
var
|
|
3766
|
+
var import_node_path5 = require("path");
|
|
3167
3767
|
var import_minimatch3 = require("minimatch");
|
|
3168
3768
|
var import_yaml2 = require("yaml");
|
|
3169
3769
|
var FernignoreMigrator = class {
|
|
@@ -3176,10 +3776,10 @@ var FernignoreMigrator = class {
|
|
|
3176
3776
|
this.outputDir = outputDir;
|
|
3177
3777
|
}
|
|
3178
3778
|
fernignoreExists() {
|
|
3179
|
-
return (0, import_node_fs3.existsSync)((0,
|
|
3779
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path5.join)(this.outputDir, ".fernignore"));
|
|
3180
3780
|
}
|
|
3181
3781
|
readFernignorePatterns() {
|
|
3182
|
-
const fernignorePath = (0,
|
|
3782
|
+
const fernignorePath = (0, import_node_path5.join)(this.outputDir, ".fernignore");
|
|
3183
3783
|
if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
|
|
3184
3784
|
return [];
|
|
3185
3785
|
}
|
|
@@ -3239,7 +3839,16 @@ var FernignoreMigrator = class {
|
|
|
3239
3839
|
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
3240
3840
|
base_generation: currentGen.commit_sha,
|
|
3241
3841
|
files: patchFiles,
|
|
3242
|
-
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
|
|
3243
3852
|
});
|
|
3244
3853
|
trackedByBoth.push({
|
|
3245
3854
|
file: pattern,
|
|
@@ -3272,7 +3881,7 @@ var FernignoreMigrator = class {
|
|
|
3272
3881
|
return results;
|
|
3273
3882
|
}
|
|
3274
3883
|
readFileContent(filePath) {
|
|
3275
|
-
const fullPath = (0,
|
|
3884
|
+
const fullPath = (0, import_node_path5.join)(this.outputDir, filePath);
|
|
3276
3885
|
if (!(0, import_node_fs3.existsSync)(fullPath)) {
|
|
3277
3886
|
return null;
|
|
3278
3887
|
}
|
|
@@ -3337,7 +3946,7 @@ var FernignoreMigrator = class {
|
|
|
3337
3946
|
};
|
|
3338
3947
|
}
|
|
3339
3948
|
movePatternsToReplayYml(patterns) {
|
|
3340
|
-
const replayYmlPath = (0,
|
|
3949
|
+
const replayYmlPath = (0, import_node_path5.join)(this.outputDir, ".fern", "replay.yml");
|
|
3341
3950
|
let config = {};
|
|
3342
3951
|
if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
|
|
3343
3952
|
const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
|
|
@@ -3346,7 +3955,7 @@ var FernignoreMigrator = class {
|
|
|
3346
3955
|
const existing = config.exclude ?? [];
|
|
3347
3956
|
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
3348
3957
|
config.exclude = merged;
|
|
3349
|
-
const dir = (0,
|
|
3958
|
+
const dir = (0, import_node_path5.dirname)(replayYmlPath);
|
|
3350
3959
|
if (!(0, import_node_fs3.existsSync)(dir)) {
|
|
3351
3960
|
(0, import_node_fs3.mkdirSync)(dir, { recursive: true });
|
|
3352
3961
|
}
|
|
@@ -3357,7 +3966,7 @@ var FernignoreMigrator = class {
|
|
|
3357
3966
|
// src/commands/bootstrap.ts
|
|
3358
3967
|
var import_node_crypto3 = require("crypto");
|
|
3359
3968
|
var import_node_fs4 = require("fs");
|
|
3360
|
-
var
|
|
3969
|
+
var import_node_path6 = require("path");
|
|
3361
3970
|
init_GitClient();
|
|
3362
3971
|
async function bootstrap(outputDir, options) {
|
|
3363
3972
|
const git = new GitClient(outputDir);
|
|
@@ -3524,7 +4133,7 @@ function parseGitLog(log) {
|
|
|
3524
4133
|
}
|
|
3525
4134
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
3526
4135
|
function ensureFernignoreEntries(outputDir) {
|
|
3527
|
-
const fernignorePath = (0,
|
|
4136
|
+
const fernignorePath = (0, import_node_path6.join)(outputDir, ".fernignore");
|
|
3528
4137
|
let content = "";
|
|
3529
4138
|
if ((0, import_node_fs4.existsSync)(fernignorePath)) {
|
|
3530
4139
|
content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
|
|
@@ -3548,7 +4157,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
3548
4157
|
}
|
|
3549
4158
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
3550
4159
|
function ensureGitattributesEntries(outputDir) {
|
|
3551
|
-
const gitattributesPath = (0,
|
|
4160
|
+
const gitattributesPath = (0, import_node_path6.join)(outputDir, ".gitattributes");
|
|
3552
4161
|
let content = "";
|
|
3553
4162
|
if ((0, import_node_fs4.existsSync)(gitattributesPath)) {
|
|
3554
4163
|
content = (0, import_node_fs4.readFileSync)(gitattributesPath, "utf-8");
|
|
@@ -3746,6 +4355,8 @@ function reset(outputDir, options) {
|
|
|
3746
4355
|
}
|
|
3747
4356
|
|
|
3748
4357
|
// src/commands/resolve.ts
|
|
4358
|
+
var import_promises3 = require("fs/promises");
|
|
4359
|
+
var import_node_path7 = require("path");
|
|
3749
4360
|
init_GitClient();
|
|
3750
4361
|
async function resolve(outputDir, options) {
|
|
3751
4362
|
const lockManager = new LockfileManager(outputDir);
|
|
@@ -3761,7 +4372,7 @@ async function resolve(outputDir, options) {
|
|
|
3761
4372
|
const resolvingPatches = lockManager.getResolvingPatches();
|
|
3762
4373
|
if (unresolvedPatches.length > 0) {
|
|
3763
4374
|
const applicator = new ReplayApplicator(git, lockManager, outputDir);
|
|
3764
|
-
await applicator.applyPatches(unresolvedPatches);
|
|
4375
|
+
await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
|
|
3765
4376
|
const markerFiles = await findConflictMarkerFiles(git);
|
|
3766
4377
|
if (markerFiles.length > 0) {
|
|
3767
4378
|
for (const patch of unresolvedPatches) {
|
|
@@ -3787,20 +4398,38 @@ async function resolve(outputDir, options) {
|
|
|
3787
4398
|
if (patchesToCommit.length > 0) {
|
|
3788
4399
|
const currentGen = lock.current_generation;
|
|
3789
4400
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4401
|
+
const warnings = [];
|
|
3790
4402
|
let patchesResolved = 0;
|
|
3791
4403
|
for (const patch of patchesToCommit) {
|
|
3792
|
-
const
|
|
3793
|
-
|
|
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()) {
|
|
3794
4418
|
lockManager.removePatch(patch.id);
|
|
3795
4419
|
continue;
|
|
3796
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
|
+
}
|
|
3797
4426
|
const newContentHash = detector.computeContentHash(diff);
|
|
3798
|
-
const changedFiles = await getChangedFiles(git, currentGen, patch.files);
|
|
4427
|
+
const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
|
|
3799
4428
|
lockManager.markPatchResolved(patch.id, {
|
|
3800
4429
|
patch_content: diff,
|
|
3801
4430
|
content_hash: newContentHash,
|
|
3802
4431
|
base_generation: currentGen,
|
|
3803
|
-
files: changedFiles
|
|
4432
|
+
files: changedFiles.length > 0 ? changedFiles : patch.files
|
|
3804
4433
|
});
|
|
3805
4434
|
patchesResolved++;
|
|
3806
4435
|
}
|
|
@@ -3816,7 +4445,8 @@ async function resolve(outputDir, options) {
|
|
|
3816
4445
|
success: true,
|
|
3817
4446
|
commitSha: commitSha2,
|
|
3818
4447
|
phase: "committed",
|
|
3819
|
-
patchesResolved
|
|
4448
|
+
patchesResolved,
|
|
4449
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
3820
4450
|
};
|
|
3821
4451
|
}
|
|
3822
4452
|
const committer = new ReplayCommitter(git, outputDir);
|
|
@@ -3834,6 +4464,24 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
3834
4464
|
const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
|
|
3835
4465
|
return changed.length > 0 ? changed : files;
|
|
3836
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
|
+
}
|
|
3837
4485
|
|
|
3838
4486
|
// src/commands/status.ts
|
|
3839
4487
|
function status(outputDir) {
|