@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.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) => {
|
|
@@ -708,17 +708,57 @@ var ReplayDetector = class {
|
|
|
708
708
|
}
|
|
709
709
|
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
710
710
|
if (!isAncestor) {
|
|
711
|
+
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
712
|
+
}
|
|
713
|
+
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* FER-10201 — Non-linear-history detection via `merge-base(prevGen, HEAD)`.
|
|
717
|
+
*
|
|
718
|
+
* After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
|
|
719
|
+
* `[fern-generated]`) is orphaned but still in git's object database. The
|
|
720
|
+
* merge-base is the commit on main from which the bot's branch was created —
|
|
721
|
+
* a reachable ancestor of HEAD. Walking that range and classifying each
|
|
722
|
+
* commit via `isGenerationCommit` mirrors the linear path and eliminates
|
|
723
|
+
* the bug class where pipeline-driven changes (autoversion, replay,
|
|
724
|
+
* generation) get bundled as customer customizations.
|
|
725
|
+
*
|
|
726
|
+
* Falls through to the legacy composite path when no common ancestor exists
|
|
727
|
+
* (truly disjoint history — orphan branches with no shared root).
|
|
728
|
+
*/
|
|
729
|
+
async detectPatchesViaMergeBase(lastGen, lock) {
|
|
730
|
+
let mergeBase = "";
|
|
731
|
+
try {
|
|
732
|
+
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
if (!mergeBase) {
|
|
736
|
+
this.warnings.push(
|
|
737
|
+
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
738
|
+
);
|
|
711
739
|
return this.detectPatchesViaTreeDiff(
|
|
712
740
|
lastGen,
|
|
713
741
|
/* commitKnownMissing */
|
|
714
742
|
false
|
|
715
743
|
);
|
|
716
744
|
}
|
|
745
|
+
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* FER-10201 — Walk `${rangeStart}..HEAD`, classify each commit, emit one
|
|
749
|
+
* `StoredPatch` per customer commit. Body shared by the linear path
|
|
750
|
+
* (rangeStart = lastGen.commit_sha) and the merge-base path.
|
|
751
|
+
*
|
|
752
|
+
* Patch `base_generation` is always `lastGen.commit_sha` — the
|
|
753
|
+
* lockfile-tracked reference the applicator uses to fetch base file
|
|
754
|
+
* content, regardless of where the walk actually started.
|
|
755
|
+
*/
|
|
756
|
+
async detectPatchesInRange(rangeStart, lastGen, lock) {
|
|
717
757
|
const log = await this.git.exec([
|
|
718
758
|
"log",
|
|
719
759
|
"--first-parent",
|
|
720
760
|
"--format=%H%x00%an%x00%ae%x00%s",
|
|
721
|
-
`${
|
|
761
|
+
`${rangeStart}..HEAD`,
|
|
722
762
|
"--",
|
|
723
763
|
this.sdkOutputDir
|
|
724
764
|
]);
|
|
@@ -1218,11 +1258,43 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
1218
1258
|
}
|
|
1219
1259
|
}
|
|
1220
1260
|
}
|
|
1221
|
-
|
|
1261
|
+
const result = {
|
|
1222
1262
|
content: outputLines.join("\n"),
|
|
1223
1263
|
hasConflicts: conflicts.length > 0,
|
|
1224
1264
|
conflicts
|
|
1225
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;
|
|
1226
1298
|
}
|
|
1227
1299
|
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
1228
1300
|
if (baseLines.length === 0) {
|
|
@@ -1417,6 +1489,19 @@ var ReplayApplicator = class {
|
|
|
1417
1489
|
renameCache = /* @__PURE__ */ new Map();
|
|
1418
1490
|
treeExistsCache = /* @__PURE__ */ new Map();
|
|
1419
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";
|
|
1420
1505
|
constructor(git, lockManager, outputDir) {
|
|
1421
1506
|
this.git = git;
|
|
1422
1507
|
this.lockManager = lockManager;
|
|
@@ -1464,8 +1549,23 @@ var ReplayApplicator = class {
|
|
|
1464
1549
|
/**
|
|
1465
1550
|
* Apply all patches, returning results for each.
|
|
1466
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.
|
|
1467
1565
|
*/
|
|
1468
|
-
async applyPatches(patches) {
|
|
1566
|
+
async applyPatches(patches, opts) {
|
|
1567
|
+
const applyMode = opts?.applyMode ?? "replay";
|
|
1568
|
+
this.currentApplyMode = applyMode;
|
|
1469
1569
|
this.resetAccumulator();
|
|
1470
1570
|
const results = [];
|
|
1471
1571
|
for (let i = 0; i < patches.length; i++) {
|
|
@@ -1480,7 +1580,7 @@ var ReplayApplicator = class {
|
|
|
1480
1580
|
}
|
|
1481
1581
|
const result = await this.applyPatchWithFallback(patch);
|
|
1482
1582
|
results.push(result);
|
|
1483
|
-
if (result.status === "conflict" && result.fileResults) {
|
|
1583
|
+
if (applyMode !== "resolve" && result.status === "conflict" && result.fileResults) {
|
|
1484
1584
|
const laterFiles = /* @__PURE__ */ new Set();
|
|
1485
1585
|
for (let j = i + 1; j < patches.length; j++) {
|
|
1486
1586
|
for (const f of patches[j].files) {
|
|
@@ -1510,9 +1610,17 @@ var ReplayApplicator = class {
|
|
|
1510
1610
|
}
|
|
1511
1611
|
return results;
|
|
1512
1612
|
}
|
|
1513
|
-
/**
|
|
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
|
+
*/
|
|
1514
1621
|
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
1515
|
-
|
|
1622
|
+
const fileResults = [];
|
|
1623
|
+
if (!baseGen) return fileResults;
|
|
1516
1624
|
const tempDir = await mkdtemp(join2(tmpdir(), "replay-acc-"));
|
|
1517
1625
|
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
1518
1626
|
const tempGit = new GitClient2(tempDir);
|
|
@@ -1525,18 +1633,30 @@ var ReplayApplicator = class {
|
|
|
1525
1633
|
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
1526
1634
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
1527
1635
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
1528
|
-
const
|
|
1636
|
+
const finalOnDisk = await readFile(join2(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
1637
|
+
const effectiveTheirs = theirs ?? finalOnDisk;
|
|
1529
1638
|
if (effectiveTheirs != null) {
|
|
1530
1639
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1531
1640
|
content: effectiveTheirs,
|
|
1532
1641
|
baseGeneration: patch.base_generation
|
|
1533
1642
|
});
|
|
1534
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
|
+
}
|
|
1535
1654
|
}
|
|
1536
1655
|
} finally {
|
|
1537
1656
|
await rm(tempDir, { recursive: true }).catch(() => {
|
|
1538
1657
|
});
|
|
1539
1658
|
}
|
|
1659
|
+
return fileResults;
|
|
1540
1660
|
}
|
|
1541
1661
|
async applyPatchWithFallback(patch) {
|
|
1542
1662
|
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
@@ -1567,11 +1687,12 @@ var ReplayApplicator = class {
|
|
|
1567
1687
|
}
|
|
1568
1688
|
try {
|
|
1569
1689
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
1570
|
-
await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1690
|
+
const fastPathFileResults = await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
1571
1691
|
return {
|
|
1572
1692
|
patch,
|
|
1573
1693
|
status: "applied",
|
|
1574
1694
|
method: "git-am",
|
|
1695
|
+
...fastPathFileResults.length > 0 && { fileResults: fastPathFileResults },
|
|
1575
1696
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
1576
1697
|
};
|
|
1577
1698
|
} catch {
|
|
@@ -1683,19 +1804,23 @@ var ReplayApplicator = class {
|
|
|
1683
1804
|
renameSourcePath
|
|
1684
1805
|
);
|
|
1685
1806
|
}
|
|
1807
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1686
1808
|
if (theirs) {
|
|
1687
1809
|
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1688
1810
|
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1689
1811
|
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1812
|
+
if (accumulatorEntry) {
|
|
1813
|
+
theirs = null;
|
|
1814
|
+
} else {
|
|
1815
|
+
return {
|
|
1816
|
+
file: resolvedPath,
|
|
1817
|
+
status: "skipped",
|
|
1818
|
+
reason: "stale-conflict-markers"
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1695
1821
|
}
|
|
1696
1822
|
}
|
|
1697
1823
|
let useAccumulatorAsMergeBase = false;
|
|
1698
|
-
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
1699
1824
|
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
1700
1825
|
theirs = await this.applyPatchToContent(
|
|
1701
1826
|
accumulatorEntry.content,
|
|
@@ -1802,7 +1927,8 @@ var ReplayApplicator = class {
|
|
|
1802
1927
|
const outDir = dirname2(oursPath);
|
|
1803
1928
|
await mkdir(outDir, { recursive: true });
|
|
1804
1929
|
await writeFile(oursPath, merged.content);
|
|
1805
|
-
|
|
1930
|
+
const populateAccumulator = effective_theirs != null && (!merged.hasConflicts || this.currentApplyMode === "resolve");
|
|
1931
|
+
if (populateAccumulator) {
|
|
1806
1932
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
1807
1933
|
content: effective_theirs,
|
|
1808
1934
|
baseGeneration: patch.base_generation
|
|
@@ -1817,7 +1943,11 @@ var ReplayApplicator = class {
|
|
|
1817
1943
|
conflictMetadata: metadata
|
|
1818
1944
|
};
|
|
1819
1945
|
}
|
|
1820
|
-
return {
|
|
1946
|
+
return {
|
|
1947
|
+
file: resolvedPath,
|
|
1948
|
+
status: "merged",
|
|
1949
|
+
...merged.droppedContextLines && merged.droppedContextLines.length > 0 ? { droppedContextLines: merged.droppedContextLines } : {}
|
|
1950
|
+
};
|
|
1821
1951
|
} catch (error) {
|
|
1822
1952
|
return {
|
|
1823
1953
|
file: filePath,
|
|
@@ -2003,6 +2133,30 @@ function isBinaryFile(filePath) {
|
|
|
2003
2133
|
const ext = extname(filePath).toLowerCase();
|
|
2004
2134
|
return BINARY_EXTENSIONS.has(ext);
|
|
2005
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
|
+
}
|
|
2006
2160
|
function isDiffLineForFile(diffLine, filePath) {
|
|
2007
2161
|
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
2008
2162
|
return match !== null && match[1] === filePath;
|
|
@@ -2038,13 +2192,46 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2038
2192
|
await this.git.exec(["commit", "-m", fullMessage]);
|
|
2039
2193
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2040
2194
|
}
|
|
2041
|
-
async commitReplay(_patchCount, patches, message) {
|
|
2195
|
+
async commitReplay(_patchCount, patches, message, options) {
|
|
2042
2196
|
await this.stageAll();
|
|
2043
2197
|
if (!await this.hasStagedChanges()) {
|
|
2044
2198
|
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
2045
2199
|
}
|
|
2046
2200
|
let fullMessage = message ?? `[fern-replay] Applied customizations`;
|
|
2047
|
-
|
|
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) {
|
|
2048
2235
|
fullMessage += "\n\nPatches replayed:";
|
|
2049
2236
|
for (const patch of patches) {
|
|
2050
2237
|
fullMessage += `
|
|
@@ -2080,9 +2267,303 @@ CLI Version: ${options.cliVersion}`;
|
|
|
2080
2267
|
|
|
2081
2268
|
// src/ReplayService.ts
|
|
2082
2269
|
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2083
|
-
import { join as
|
|
2270
|
+
import { join as join4 } from "path";
|
|
2084
2271
|
import { minimatch as minimatch2 } from "minimatch";
|
|
2085
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
|
|
2086
2567
|
var ReplayService = class {
|
|
2087
2568
|
git;
|
|
2088
2569
|
detector;
|
|
@@ -2476,6 +2957,7 @@ var ReplayService = class {
|
|
|
2476
2957
|
if (newPatches.length > 0) {
|
|
2477
2958
|
results = await this.applicator.applyPatches(newPatches);
|
|
2478
2959
|
this._lastApplyResults = results;
|
|
2960
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2479
2961
|
this.revertConflictingFiles(results);
|
|
2480
2962
|
for (const result of results) {
|
|
2481
2963
|
if (result.status === "conflict") {
|
|
@@ -2494,7 +2976,8 @@ var ReplayService = class {
|
|
|
2494
2976
|
if (newPatches.length > 0) {
|
|
2495
2977
|
if (!options?.stageOnly) {
|
|
2496
2978
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2497
|
-
|
|
2979
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, newPatches);
|
|
2980
|
+
await this.committer.commitReplay(appliedCount, newPatches, void 0, { buckets });
|
|
2498
2981
|
} else {
|
|
2499
2982
|
await this.committer.stageAll();
|
|
2500
2983
|
}
|
|
@@ -2557,12 +3040,21 @@ var ReplayService = class {
|
|
|
2557
3040
|
(p) => preRebasePatchIds.has(p.id) && !postRebasePatchIds.has(p.id)
|
|
2558
3041
|
);
|
|
2559
3042
|
existingPatches = this.lockManager.getPatches();
|
|
2560
|
-
const seenHashes = /* @__PURE__ */ new
|
|
3043
|
+
const seenHashes = /* @__PURE__ */ new Map();
|
|
2561
3044
|
for (const p of existingPatches) {
|
|
2562
|
-
|
|
2563
|
-
|
|
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
|
+
}
|
|
2564
3056
|
} else {
|
|
2565
|
-
seenHashes.
|
|
3057
|
+
seenHashes.set(p.content_hash, p.original_commit);
|
|
2566
3058
|
}
|
|
2567
3059
|
}
|
|
2568
3060
|
existingPatches = this.lockManager.getPatches();
|
|
@@ -2623,6 +3115,7 @@ var ReplayService = class {
|
|
|
2623
3115
|
const genSha = prep._prepared.genSha;
|
|
2624
3116
|
const results = await this.applicator.applyPatches(allPatches);
|
|
2625
3117
|
this._lastApplyResults = results;
|
|
3118
|
+
this.recordDroppedContextLineWarnings(results);
|
|
2626
3119
|
this.revertConflictingFiles(results);
|
|
2627
3120
|
for (const result of results) {
|
|
2628
3121
|
if (result.status === "conflict") {
|
|
@@ -2650,7 +3143,8 @@ var ReplayService = class {
|
|
|
2650
3143
|
await this.committer.stageAll();
|
|
2651
3144
|
} else {
|
|
2652
3145
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
2653
|
-
|
|
3146
|
+
const buckets = computeCommitBuckets(results, rebaseCounts.absorbedPatchIds, allPatches);
|
|
3147
|
+
await this.committer.commitReplay(appliedCount, allPatches, void 0, { buckets });
|
|
2654
3148
|
}
|
|
2655
3149
|
const warnings = [...detectorWarnings, ...this.warnings];
|
|
2656
3150
|
return this.buildReport(
|
|
@@ -2674,7 +3168,7 @@ var ReplayService = class {
|
|
|
2674
3168
|
let repointed = 0;
|
|
2675
3169
|
let contentRebased = 0;
|
|
2676
3170
|
let keptAsUserOwned = 0;
|
|
2677
|
-
const seenContentHashes = /* @__PURE__ */ new
|
|
3171
|
+
const seenContentHashes = /* @__PURE__ */ new Map();
|
|
2678
3172
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
2679
3173
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
2680
3174
|
for (const result of results) {
|
|
@@ -2688,6 +3182,23 @@ var ReplayService = class {
|
|
|
2688
3182
|
}
|
|
2689
3183
|
}
|
|
2690
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
|
+
}
|
|
2691
3202
|
for (const result of results) {
|
|
2692
3203
|
if (result.status === "conflict" && result.fileResults) {
|
|
2693
3204
|
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
@@ -2755,6 +3266,18 @@ var ReplayService = class {
|
|
|
2755
3266
|
keptAsUserOwned++;
|
|
2756
3267
|
continue;
|
|
2757
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
|
+
}
|
|
2758
3281
|
this.lockManager.removePatch(patch.id);
|
|
2759
3282
|
absorbedPatchIds.add(patch.id);
|
|
2760
3283
|
absorbed++;
|
|
@@ -2770,13 +3293,22 @@ var ReplayService = class {
|
|
|
2770
3293
|
continue;
|
|
2771
3294
|
}
|
|
2772
3295
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
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);
|
|
2778
3311
|
}
|
|
2779
|
-
seenContentHashes.add(newContentHash);
|
|
2780
3312
|
patch.base_generation = currentGenSha;
|
|
2781
3313
|
patch.patch_content = diff;
|
|
2782
3314
|
patch.content_hash = newContentHash;
|
|
@@ -2952,8 +3484,14 @@ var ReplayService = class {
|
|
|
2952
3484
|
}
|
|
2953
3485
|
if (patch.base_generation === currentGen) {
|
|
2954
3486
|
try {
|
|
2955
|
-
const
|
|
2956
|
-
|
|
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;
|
|
2957
3495
|
if (!diff.trim()) {
|
|
2958
3496
|
if (await allPatchFilesUserOwned(patch)) continue;
|
|
2959
3497
|
this.lockManager.removePatch(patch.id);
|
|
@@ -2995,8 +3533,14 @@ var ReplayService = class {
|
|
|
2995
3533
|
try {
|
|
2996
3534
|
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
2997
3535
|
if (markerFiles.trim()) continue;
|
|
2998
|
-
const
|
|
2999
|
-
|
|
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;
|
|
3000
3544
|
if (!diff.trim()) {
|
|
3001
3545
|
if (await allPatchFilesUserOwned(patch)) {
|
|
3002
3546
|
try {
|
|
@@ -3021,6 +3565,32 @@ var ReplayService = class {
|
|
|
3021
3565
|
}
|
|
3022
3566
|
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
3023
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
|
+
}
|
|
3024
3594
|
/**
|
|
3025
3595
|
* After applyPatches(), strip conflict markers from conflicting files
|
|
3026
3596
|
* so only clean content is committed. Keeps the Generated (OURS) side.
|
|
@@ -3030,7 +3600,7 @@ var ReplayService = class {
|
|
|
3030
3600
|
if (result.status !== "conflict" || !result.fileResults) continue;
|
|
3031
3601
|
for (const fileResult of result.fileResults) {
|
|
3032
3602
|
if (fileResult.status !== "conflict") continue;
|
|
3033
|
-
const filePath =
|
|
3603
|
+
const filePath = join4(this.outputDir, fileResult.file);
|
|
3034
3604
|
try {
|
|
3035
3605
|
const content = readFileSync2(filePath, "utf-8");
|
|
3036
3606
|
const stripped = stripConflictMarkers(content);
|
|
@@ -3060,7 +3630,7 @@ var ReplayService = class {
|
|
|
3060
3630
|
}
|
|
3061
3631
|
}
|
|
3062
3632
|
readFernignorePatterns() {
|
|
3063
|
-
const fernignorePath =
|
|
3633
|
+
const fernignorePath = join4(this.outputDir, ".fernignore");
|
|
3064
3634
|
if (!existsSync2(fernignorePath)) return [];
|
|
3065
3635
|
return readFileSync2(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
3066
3636
|
}
|
|
@@ -3078,6 +3648,9 @@ var ReplayService = class {
|
|
|
3078
3648
|
};
|
|
3079
3649
|
}).filter((d) => d.files.length > 0);
|
|
3080
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
|
+
);
|
|
3081
3654
|
return {
|
|
3082
3655
|
flow,
|
|
3083
3656
|
patchesDetected: patches.length,
|
|
@@ -3094,7 +3667,7 @@ var ReplayService = class {
|
|
|
3094
3667
|
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
3095
3668
|
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
3096
3669
|
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
3097
|
-
unresolvedPatches:
|
|
3670
|
+
unresolvedPatches: unresolvedResults.length > 0 ? unresolvedResults.map((r) => ({
|
|
3098
3671
|
patchId: r.patch.id,
|
|
3099
3672
|
patchMessage: r.patch.original_message,
|
|
3100
3673
|
files: r.patch.files,
|
|
@@ -3105,11 +3678,38 @@ var ReplayService = class {
|
|
|
3105
3678
|
};
|
|
3106
3679
|
}
|
|
3107
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
|
+
}
|
|
3108
3708
|
|
|
3109
3709
|
// src/FernignoreMigrator.ts
|
|
3110
3710
|
import { createHash as createHash2 } from "crypto";
|
|
3111
3711
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
3112
|
-
import { dirname as dirname3, join as
|
|
3712
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
3113
3713
|
import { minimatch as minimatch3 } from "minimatch";
|
|
3114
3714
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
3115
3715
|
var FernignoreMigrator = class {
|
|
@@ -3122,10 +3722,10 @@ var FernignoreMigrator = class {
|
|
|
3122
3722
|
this.outputDir = outputDir;
|
|
3123
3723
|
}
|
|
3124
3724
|
fernignoreExists() {
|
|
3125
|
-
return existsSync3(
|
|
3725
|
+
return existsSync3(join5(this.outputDir, ".fernignore"));
|
|
3126
3726
|
}
|
|
3127
3727
|
readFernignorePatterns() {
|
|
3128
|
-
const fernignorePath =
|
|
3728
|
+
const fernignorePath = join5(this.outputDir, ".fernignore");
|
|
3129
3729
|
if (!existsSync3(fernignorePath)) {
|
|
3130
3730
|
return [];
|
|
3131
3731
|
}
|
|
@@ -3185,7 +3785,16 @@ var FernignoreMigrator = class {
|
|
|
3185
3785
|
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
3186
3786
|
base_generation: currentGen.commit_sha,
|
|
3187
3787
|
files: patchFiles,
|
|
3188
|
-
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
|
|
3189
3798
|
});
|
|
3190
3799
|
trackedByBoth.push({
|
|
3191
3800
|
file: pattern,
|
|
@@ -3218,7 +3827,7 @@ var FernignoreMigrator = class {
|
|
|
3218
3827
|
return results;
|
|
3219
3828
|
}
|
|
3220
3829
|
readFileContent(filePath) {
|
|
3221
|
-
const fullPath =
|
|
3830
|
+
const fullPath = join5(this.outputDir, filePath);
|
|
3222
3831
|
if (!existsSync3(fullPath)) {
|
|
3223
3832
|
return null;
|
|
3224
3833
|
}
|
|
@@ -3283,7 +3892,7 @@ var FernignoreMigrator = class {
|
|
|
3283
3892
|
};
|
|
3284
3893
|
}
|
|
3285
3894
|
movePatternsToReplayYml(patterns) {
|
|
3286
|
-
const replayYmlPath =
|
|
3895
|
+
const replayYmlPath = join5(this.outputDir, ".fern", "replay.yml");
|
|
3287
3896
|
let config = {};
|
|
3288
3897
|
if (existsSync3(replayYmlPath)) {
|
|
3289
3898
|
const content = readFileSync3(replayYmlPath, "utf-8");
|
|
@@ -3303,7 +3912,7 @@ var FernignoreMigrator = class {
|
|
|
3303
3912
|
// src/commands/bootstrap.ts
|
|
3304
3913
|
import { createHash as createHash3 } from "crypto";
|
|
3305
3914
|
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
3306
|
-
import { join as
|
|
3915
|
+
import { join as join6 } from "path";
|
|
3307
3916
|
init_GitClient();
|
|
3308
3917
|
async function bootstrap(outputDir, options) {
|
|
3309
3918
|
const git = new GitClient(outputDir);
|
|
@@ -3470,7 +4079,7 @@ function parseGitLog(log) {
|
|
|
3470
4079
|
}
|
|
3471
4080
|
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
3472
4081
|
function ensureFernignoreEntries(outputDir) {
|
|
3473
|
-
const fernignorePath =
|
|
4082
|
+
const fernignorePath = join6(outputDir, ".fernignore");
|
|
3474
4083
|
let content = "";
|
|
3475
4084
|
if (existsSync4(fernignorePath)) {
|
|
3476
4085
|
content = readFileSync4(fernignorePath, "utf-8");
|
|
@@ -3494,7 +4103,7 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
3494
4103
|
}
|
|
3495
4104
|
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
3496
4105
|
function ensureGitattributesEntries(outputDir) {
|
|
3497
|
-
const gitattributesPath =
|
|
4106
|
+
const gitattributesPath = join6(outputDir, ".gitattributes");
|
|
3498
4107
|
let content = "";
|
|
3499
4108
|
if (existsSync4(gitattributesPath)) {
|
|
3500
4109
|
content = readFileSync4(gitattributesPath, "utf-8");
|
|
@@ -3693,6 +4302,8 @@ function reset(outputDir, options) {
|
|
|
3693
4302
|
|
|
3694
4303
|
// src/commands/resolve.ts
|
|
3695
4304
|
init_GitClient();
|
|
4305
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
4306
|
+
import { join as join7 } from "path";
|
|
3696
4307
|
async function resolve(outputDir, options) {
|
|
3697
4308
|
const lockManager = new LockfileManager(outputDir);
|
|
3698
4309
|
if (!lockManager.exists()) {
|
|
@@ -3707,7 +4318,7 @@ async function resolve(outputDir, options) {
|
|
|
3707
4318
|
const resolvingPatches = lockManager.getResolvingPatches();
|
|
3708
4319
|
if (unresolvedPatches.length > 0) {
|
|
3709
4320
|
const applicator = new ReplayApplicator(git, lockManager, outputDir);
|
|
3710
|
-
await applicator.applyPatches(unresolvedPatches);
|
|
4321
|
+
await applicator.applyPatches(unresolvedPatches, { applyMode: "resolve" });
|
|
3711
4322
|
const markerFiles = await findConflictMarkerFiles(git);
|
|
3712
4323
|
if (markerFiles.length > 0) {
|
|
3713
4324
|
for (const patch of unresolvedPatches) {
|
|
@@ -3733,20 +4344,38 @@ async function resolve(outputDir, options) {
|
|
|
3733
4344
|
if (patchesToCommit.length > 0) {
|
|
3734
4345
|
const currentGen = lock.current_generation;
|
|
3735
4346
|
const detector = new ReplayDetector(git, lockManager, outputDir);
|
|
4347
|
+
const warnings = [];
|
|
3736
4348
|
let patchesResolved = 0;
|
|
3737
4349
|
for (const patch of patchesToCommit) {
|
|
3738
|
-
const
|
|
3739
|
-
|
|
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()) {
|
|
3740
4364
|
lockManager.removePatch(patch.id);
|
|
3741
4365
|
continue;
|
|
3742
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
|
+
}
|
|
3743
4372
|
const newContentHash = detector.computeContentHash(diff);
|
|
3744
|
-
const changedFiles = await getChangedFiles(git, currentGen, patch.files);
|
|
4373
|
+
const changedFiles = result.hadFallback ? await getChangedFiles(git, currentGen, patch.files) : extractFilesFromDiff(diff);
|
|
3745
4374
|
lockManager.markPatchResolved(patch.id, {
|
|
3746
4375
|
patch_content: diff,
|
|
3747
4376
|
content_hash: newContentHash,
|
|
3748
4377
|
base_generation: currentGen,
|
|
3749
|
-
files: changedFiles
|
|
4378
|
+
files: changedFiles.length > 0 ? changedFiles : patch.files
|
|
3750
4379
|
});
|
|
3751
4380
|
patchesResolved++;
|
|
3752
4381
|
}
|
|
@@ -3762,7 +4391,8 @@ async function resolve(outputDir, options) {
|
|
|
3762
4391
|
success: true,
|
|
3763
4392
|
commitSha: commitSha2,
|
|
3764
4393
|
phase: "committed",
|
|
3765
|
-
patchesResolved
|
|
4394
|
+
patchesResolved,
|
|
4395
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
3766
4396
|
};
|
|
3767
4397
|
}
|
|
3768
4398
|
const committer = new ReplayCommitter(git, outputDir);
|
|
@@ -3780,6 +4410,24 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
3780
4410
|
const changed = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/"));
|
|
3781
4411
|
return changed.length > 0 ? changed : files;
|
|
3782
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
|
+
}
|
|
3783
4431
|
|
|
3784
4432
|
// src/commands/status.ts
|
|
3785
4433
|
function status(outputDir) {
|