@fern-api/replay 0.9.0 → 0.10.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 +745 -76
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +716 -76
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -1
- package/dist/index.d.ts +41 -1
- package/dist/index.js +716 -77
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -111,6 +111,14 @@ var init_GitClient = __esm({
|
|
|
111
111
|
return false;
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
+
async treeExists(treeHash) {
|
|
115
|
+
try {
|
|
116
|
+
const type = await this.exec(["cat-file", "-t", treeHash]);
|
|
117
|
+
return type.trim() === "tree";
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
114
122
|
async getCommitBody(commitSha) {
|
|
115
123
|
return this.exec(["log", "-1", "--format=%B", commitSha]);
|
|
116
124
|
}
|
|
@@ -121,6 +129,255 @@ var init_GitClient = __esm({
|
|
|
121
129
|
}
|
|
122
130
|
});
|
|
123
131
|
|
|
132
|
+
// src/HybridReconstruction.ts
|
|
133
|
+
var HybridReconstruction_exports = {};
|
|
134
|
+
__export(HybridReconstruction_exports, {
|
|
135
|
+
assembleHybrid: () => assembleHybrid,
|
|
136
|
+
locateHunksInOurs: () => locateHunksInOurs,
|
|
137
|
+
parseHunks: () => parseHunks,
|
|
138
|
+
reconstructFromGhostPatch: () => reconstructFromGhostPatch
|
|
139
|
+
});
|
|
140
|
+
function parseHunks(fileDiff) {
|
|
141
|
+
const lines = fileDiff.split("\n");
|
|
142
|
+
const hunks = [];
|
|
143
|
+
let currentHunk = null;
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
const headerMatch = line.match(
|
|
146
|
+
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
147
|
+
);
|
|
148
|
+
if (headerMatch) {
|
|
149
|
+
if (currentHunk) {
|
|
150
|
+
hunks.push(currentHunk);
|
|
151
|
+
}
|
|
152
|
+
currentHunk = {
|
|
153
|
+
oldStart: parseInt(headerMatch[1], 10),
|
|
154
|
+
oldCount: headerMatch[2] != null ? parseInt(headerMatch[2], 10) : 1,
|
|
155
|
+
newStart: parseInt(headerMatch[3], 10),
|
|
156
|
+
newCount: headerMatch[4] != null ? parseInt(headerMatch[4], 10) : 1,
|
|
157
|
+
lines: []
|
|
158
|
+
};
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!currentHunk) continue;
|
|
162
|
+
if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("old mode") || line.startsWith("new mode") || line.startsWith("similarity index") || line.startsWith("rename from") || line.startsWith("rename to") || line.startsWith("new file mode") || line.startsWith("deleted file mode")) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (line === "\") {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (line.startsWith("-")) {
|
|
169
|
+
currentHunk.lines.push({ type: "remove", content: line.slice(1) });
|
|
170
|
+
} else if (line.startsWith("+")) {
|
|
171
|
+
currentHunk.lines.push({ type: "add", content: line.slice(1) });
|
|
172
|
+
} else if (line.startsWith(" ") || line === "") {
|
|
173
|
+
currentHunk.lines.push({
|
|
174
|
+
type: "context",
|
|
175
|
+
content: line.startsWith(" ") ? line.slice(1) : line
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (currentHunk) {
|
|
180
|
+
hunks.push(currentHunk);
|
|
181
|
+
}
|
|
182
|
+
return hunks;
|
|
183
|
+
}
|
|
184
|
+
function extractLeadingContext(hunk) {
|
|
185
|
+
const result = [];
|
|
186
|
+
for (const line of hunk.lines) {
|
|
187
|
+
if (line.type !== "context") break;
|
|
188
|
+
result.push(line.content);
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
function extractTrailingContext(hunk) {
|
|
193
|
+
const result = [];
|
|
194
|
+
for (let i = hunk.lines.length - 1; i >= 0; i--) {
|
|
195
|
+
if (hunk.lines[i].type !== "context") break;
|
|
196
|
+
result.unshift(hunk.lines[i].content);
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
function countOursLinesBeforeTrailing(hunk) {
|
|
201
|
+
let count = 0;
|
|
202
|
+
const trailingStart = findTrailingContextStart(hunk);
|
|
203
|
+
for (let i = 0; i < trailingStart; i++) {
|
|
204
|
+
if (hunk.lines[i].type === "context") count++;
|
|
205
|
+
}
|
|
206
|
+
return count;
|
|
207
|
+
}
|
|
208
|
+
function findTrailingContextStart(hunk) {
|
|
209
|
+
let i = hunk.lines.length - 1;
|
|
210
|
+
while (i >= 0 && hunk.lines[i].type === "context") {
|
|
211
|
+
i--;
|
|
212
|
+
}
|
|
213
|
+
return i + 1;
|
|
214
|
+
}
|
|
215
|
+
function matchesAt(needle, haystack, offset) {
|
|
216
|
+
for (let i = 0; i < needle.length; i++) {
|
|
217
|
+
if (haystack[offset + i] !== needle[i]) return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
function findContextInOurs(contextLines, oursLines, minIndex, hint) {
|
|
222
|
+
const SEARCH_WINDOW = 200;
|
|
223
|
+
const maxStart = oursLines.length - contextLines.length;
|
|
224
|
+
const clampedHint = Math.max(minIndex, Math.min(hint, maxStart));
|
|
225
|
+
if (clampedHint >= minIndex && clampedHint <= maxStart) {
|
|
226
|
+
if (matchesAt(contextLines, oursLines, clampedHint)) {
|
|
227
|
+
return clampedHint;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (let delta = 1; delta <= SEARCH_WINDOW; delta++) {
|
|
231
|
+
for (const sign of [1, -1]) {
|
|
232
|
+
const idx = clampedHint + delta * sign;
|
|
233
|
+
if (idx < minIndex || idx > maxStart) continue;
|
|
234
|
+
if (matchesAt(contextLines, oursLines, idx)) {
|
|
235
|
+
return idx;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return -1;
|
|
240
|
+
}
|
|
241
|
+
function computeOursSpan(hunk, oursLines, oursOffset) {
|
|
242
|
+
const leading = extractLeadingContext(hunk);
|
|
243
|
+
const trailing = extractTrailingContext(hunk);
|
|
244
|
+
if (trailing.length === 0) {
|
|
245
|
+
const contextCount2 = hunk.lines.filter(
|
|
246
|
+
(l) => l.type === "context"
|
|
247
|
+
).length;
|
|
248
|
+
return Math.min(contextCount2, oursLines.length - oursOffset);
|
|
249
|
+
}
|
|
250
|
+
const searchStart = oursOffset + leading.length;
|
|
251
|
+
for (let i = searchStart; i <= oursLines.length - trailing.length; i++) {
|
|
252
|
+
if (matchesAt(trailing, oursLines, i)) {
|
|
253
|
+
return i + trailing.length - oursOffset;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const contextCount = hunk.lines.filter(
|
|
257
|
+
(l) => l.type === "context"
|
|
258
|
+
).length;
|
|
259
|
+
return Math.min(contextCount, oursLines.length - oursOffset);
|
|
260
|
+
}
|
|
261
|
+
function locateHunksInOurs(hunks, oursLines) {
|
|
262
|
+
const located = [];
|
|
263
|
+
let minOursIndex = 0;
|
|
264
|
+
for (const hunk of hunks) {
|
|
265
|
+
const contextLines = extractLeadingContext(hunk);
|
|
266
|
+
let oursOffset;
|
|
267
|
+
if (contextLines.length > 0) {
|
|
268
|
+
const found = findContextInOurs(
|
|
269
|
+
contextLines,
|
|
270
|
+
oursLines,
|
|
271
|
+
minOursIndex,
|
|
272
|
+
hunk.newStart - 1
|
|
273
|
+
);
|
|
274
|
+
if (found === -1) {
|
|
275
|
+
const trailingContext = extractTrailingContext(hunk);
|
|
276
|
+
if (trailingContext.length > 0) {
|
|
277
|
+
const trailingFound = findContextInOurs(
|
|
278
|
+
trailingContext,
|
|
279
|
+
oursLines,
|
|
280
|
+
minOursIndex,
|
|
281
|
+
hunk.newStart - 1
|
|
282
|
+
);
|
|
283
|
+
if (trailingFound === -1) return null;
|
|
284
|
+
const nonTrailingCount = countOursLinesBeforeTrailing(hunk);
|
|
285
|
+
oursOffset = trailingFound - nonTrailingCount;
|
|
286
|
+
if (oursOffset < minOursIndex) return null;
|
|
287
|
+
} else {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
oursOffset = found;
|
|
292
|
+
}
|
|
293
|
+
} else if (hunk.oldStart === 1 && hunk.oldCount === 0) {
|
|
294
|
+
oursOffset = 0;
|
|
295
|
+
} else {
|
|
296
|
+
oursOffset = Math.max(hunk.newStart - 1, minOursIndex);
|
|
297
|
+
}
|
|
298
|
+
const oursSpan = computeOursSpan(hunk, oursLines, oursOffset);
|
|
299
|
+
located.push({ hunk, oursOffset, oursSpan });
|
|
300
|
+
minOursIndex = oursOffset + oursSpan;
|
|
301
|
+
}
|
|
302
|
+
return located;
|
|
303
|
+
}
|
|
304
|
+
function assembleHybrid(locatedHunks, oursLines) {
|
|
305
|
+
const baseLines = [];
|
|
306
|
+
const theirsLines = [];
|
|
307
|
+
let oursCursor = 0;
|
|
308
|
+
for (const { hunk, oursOffset, oursSpan } of locatedHunks) {
|
|
309
|
+
if (oursOffset > oursCursor) {
|
|
310
|
+
const gapLines = oursLines.slice(oursCursor, oursOffset);
|
|
311
|
+
baseLines.push(...gapLines);
|
|
312
|
+
theirsLines.push(...gapLines);
|
|
313
|
+
}
|
|
314
|
+
for (const line of hunk.lines) {
|
|
315
|
+
switch (line.type) {
|
|
316
|
+
case "context":
|
|
317
|
+
baseLines.push(line.content);
|
|
318
|
+
theirsLines.push(line.content);
|
|
319
|
+
break;
|
|
320
|
+
case "remove":
|
|
321
|
+
baseLines.push(line.content);
|
|
322
|
+
break;
|
|
323
|
+
case "add":
|
|
324
|
+
theirsLines.push(line.content);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
oursCursor = oursOffset + oursSpan;
|
|
329
|
+
}
|
|
330
|
+
if (oursCursor < oursLines.length) {
|
|
331
|
+
const gapLines = oursLines.slice(oursCursor);
|
|
332
|
+
baseLines.push(...gapLines);
|
|
333
|
+
theirsLines.push(...gapLines);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
base: baseLines.join("\n"),
|
|
337
|
+
theirs: theirsLines.join("\n")
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function reconstructFromGhostPatch(fileDiff, ours) {
|
|
341
|
+
const hunks = parseHunks(fileDiff);
|
|
342
|
+
if (hunks.length === 0) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const isPureAddition = hunks.every(
|
|
346
|
+
(h) => h.oldCount === 0 && h.lines.every((l) => l.type !== "remove")
|
|
347
|
+
);
|
|
348
|
+
if (isPureAddition) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const isPureDeletion = hunks.every(
|
|
352
|
+
(h) => h.newCount === 0 && h.lines.every((l) => l.type !== "add")
|
|
353
|
+
);
|
|
354
|
+
if (isPureDeletion) {
|
|
355
|
+
const baseLines = [];
|
|
356
|
+
for (const hunk of hunks) {
|
|
357
|
+
for (const line of hunk.lines) {
|
|
358
|
+
if (line.type === "context" || line.type === "remove") {
|
|
359
|
+
baseLines.push(line.content);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
base: baseLines.join("\n"),
|
|
365
|
+
theirs: ""
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const oursLines = ours.split("\n");
|
|
369
|
+
const located = locateHunksInOurs(hunks, oursLines);
|
|
370
|
+
if (!located) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
return assembleHybrid(located, oursLines);
|
|
374
|
+
}
|
|
375
|
+
var init_HybridReconstruction = __esm({
|
|
376
|
+
"src/HybridReconstruction.ts"() {
|
|
377
|
+
"use strict";
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
124
381
|
// src/index.ts
|
|
125
382
|
init_GitClient();
|
|
126
383
|
|
|
@@ -158,6 +415,12 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
|
|
|
158
415
|
import { join, dirname } from "path";
|
|
159
416
|
import { stringify, parse } from "yaml";
|
|
160
417
|
var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
|
|
418
|
+
var LockfileNotFoundError = class extends Error {
|
|
419
|
+
constructor(path) {
|
|
420
|
+
super(`Lockfile not found: ${path}`);
|
|
421
|
+
this.name = "LockfileNotFoundError";
|
|
422
|
+
}
|
|
423
|
+
};
|
|
161
424
|
var LockfileManager = class {
|
|
162
425
|
outputDir;
|
|
163
426
|
lock = null;
|
|
@@ -177,12 +440,16 @@ var LockfileManager = class {
|
|
|
177
440
|
if (this.lock) {
|
|
178
441
|
return this.lock;
|
|
179
442
|
}
|
|
180
|
-
|
|
181
|
-
|
|
443
|
+
try {
|
|
444
|
+
const content = readFileSync(this.lockfilePath, "utf-8");
|
|
445
|
+
this.lock = parse(content);
|
|
446
|
+
return this.lock;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
449
|
+
throw new LockfileNotFoundError(this.lockfilePath);
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
182
452
|
}
|
|
183
|
-
const content = readFileSync(this.lockfilePath, "utf-8");
|
|
184
|
-
this.lock = parse(content);
|
|
185
|
-
return this.lock;
|
|
186
453
|
}
|
|
187
454
|
initialize(firstGeneration) {
|
|
188
455
|
this.initializeInMemory(firstGeneration);
|
|
@@ -328,13 +595,21 @@ var ReplayDetector = class {
|
|
|
328
595
|
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
329
596
|
if (!exists) {
|
|
330
597
|
this.warnings.push(
|
|
331
|
-
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history.
|
|
598
|
+
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
|
|
599
|
+
);
|
|
600
|
+
return this.detectPatchesViaTreeDiff(
|
|
601
|
+
lastGen,
|
|
602
|
+
/* commitKnownMissing */
|
|
603
|
+
true
|
|
332
604
|
);
|
|
333
|
-
return { patches: [], revertedPatchIds: [] };
|
|
334
605
|
}
|
|
335
606
|
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
336
607
|
if (!isAncestor) {
|
|
337
|
-
return this.detectPatchesViaTreeDiff(
|
|
608
|
+
return this.detectPatchesViaTreeDiff(
|
|
609
|
+
lastGen,
|
|
610
|
+
/* commitKnownMissing */
|
|
611
|
+
false
|
|
612
|
+
);
|
|
338
613
|
}
|
|
339
614
|
const log = await this.git.exec([
|
|
340
615
|
"log",
|
|
@@ -360,7 +635,15 @@ var ReplayDetector = class {
|
|
|
360
635
|
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
361
636
|
continue;
|
|
362
637
|
}
|
|
363
|
-
|
|
638
|
+
let patchContent;
|
|
639
|
+
try {
|
|
640
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
641
|
+
} catch {
|
|
642
|
+
this.warnings.push(
|
|
643
|
+
`Could not generate patch for commit ${commit.sha.slice(0, 7)} \u2014 it may be unreachable in a shallow clone. Skipping.`
|
|
644
|
+
);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
364
647
|
const contentHash = this.computeContentHash(patchContent);
|
|
365
648
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
366
649
|
continue;
|
|
@@ -453,11 +736,15 @@ var ReplayDetector = class {
|
|
|
453
736
|
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
454
737
|
* patch from the aggregate diff — individual revert commits are not distinguishable.
|
|
455
738
|
*/
|
|
456
|
-
async detectPatchesViaTreeDiff(lastGen) {
|
|
457
|
-
const
|
|
739
|
+
async detectPatchesViaTreeDiff(lastGen, commitKnownMissing) {
|
|
740
|
+
const diffBase = await this.resolveDiffBase(lastGen, commitKnownMissing);
|
|
741
|
+
if (!diffBase) {
|
|
742
|
+
return this.detectPatchesViaCommitScan();
|
|
743
|
+
}
|
|
744
|
+
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
458
745
|
const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
459
746
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
460
|
-
const diff = await this.git.exec(["diff",
|
|
747
|
+
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
461
748
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
462
749
|
const contentHash = this.computeContentHash(diff);
|
|
463
750
|
const lock = this.lockManager.read();
|
|
@@ -471,12 +758,113 @@ var ReplayDetector = class {
|
|
|
471
758
|
original_commit: headSha,
|
|
472
759
|
original_message: "Customer customizations (composite)",
|
|
473
760
|
original_author: "composite",
|
|
474
|
-
|
|
761
|
+
// Use diffBase when commit is unreachable — the applicator needs a reachable
|
|
762
|
+
// reference to find base file content. diffBase may be the tree_hash.
|
|
763
|
+
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
475
764
|
files,
|
|
476
765
|
patch_content: diff
|
|
477
766
|
};
|
|
478
767
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
479
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* Last-resort detection when both generation commit and tree are unreachable.
|
|
771
|
+
* Scans all commits from HEAD, filters against known lockfile patches, and
|
|
772
|
+
* skips creation-only commits (squashed history after force push).
|
|
773
|
+
*
|
|
774
|
+
* Detected patches use the commit's parent as base_generation so the applicator
|
|
775
|
+
* can find base file content from the parent's tree (which IS reachable).
|
|
776
|
+
*/
|
|
777
|
+
async detectPatchesViaCommitScan() {
|
|
778
|
+
const lock = this.lockManager.read();
|
|
779
|
+
const log = await this.git.exec([
|
|
780
|
+
"log",
|
|
781
|
+
"--max-count=200",
|
|
782
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
783
|
+
"HEAD",
|
|
784
|
+
"--",
|
|
785
|
+
this.sdkOutputDir
|
|
786
|
+
]);
|
|
787
|
+
if (!log.trim()) {
|
|
788
|
+
return { patches: [], revertedPatchIds: [] };
|
|
789
|
+
}
|
|
790
|
+
const commits = this.parseGitLog(log);
|
|
791
|
+
const newPatches = [];
|
|
792
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
793
|
+
const existingHashes = new Set(lock.patches.map((p) => p.content_hash));
|
|
794
|
+
const existingCommits = new Set(lock.patches.map((p) => p.original_commit));
|
|
795
|
+
for (const commit of commits) {
|
|
796
|
+
if (isGenerationCommit(commit)) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
800
|
+
if (parents.length > 1) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (existingCommits.has(commit.sha)) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
let patchContent;
|
|
807
|
+
try {
|
|
808
|
+
patchContent = await this.git.formatPatch(commit.sha);
|
|
809
|
+
} catch {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const contentHash = this.computeContentHash(patchContent);
|
|
813
|
+
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (this.isCreationOnlyPatch(patchContent)) {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
820
|
+
const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
821
|
+
if (files.length === 0) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (parents.length === 0) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const parentSha = parents[0];
|
|
828
|
+
newPatches.push({
|
|
829
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
830
|
+
content_hash: contentHash,
|
|
831
|
+
original_commit: commit.sha,
|
|
832
|
+
original_message: commit.message,
|
|
833
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
834
|
+
base_generation: parentSha,
|
|
835
|
+
files,
|
|
836
|
+
patch_content: patchContent
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
newPatches.reverse();
|
|
840
|
+
return { patches: newPatches, revertedPatchIds: [] };
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Check if a format-patch consists entirely of new-file creations.
|
|
844
|
+
* Used to identify squashed commits after force push, which create all files
|
|
845
|
+
* from scratch (--- /dev/null) rather than modifying existing files.
|
|
846
|
+
*/
|
|
847
|
+
isCreationOnlyPatch(patchContent) {
|
|
848
|
+
const diffOldHeaders = patchContent.split("\n").filter((l) => l.startsWith("--- "));
|
|
849
|
+
if (diffOldHeaders.length === 0) {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
return diffOldHeaders.every((l) => l === "--- /dev/null");
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Resolve the best available diff base for a generation record.
|
|
856
|
+
* Prefers commit_sha, falls back to tree_hash for unreachable commits.
|
|
857
|
+
* When commitKnownMissing is true, skips the redundant commitExists check.
|
|
858
|
+
*/
|
|
859
|
+
async resolveDiffBase(gen, commitKnownMissing) {
|
|
860
|
+
if (!commitKnownMissing && await this.git.commitExists(gen.commit_sha)) {
|
|
861
|
+
return gen.commit_sha;
|
|
862
|
+
}
|
|
863
|
+
if (await this.git.treeExists(gen.tree_hash)) {
|
|
864
|
+
return gen.tree_hash;
|
|
865
|
+
}
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
480
868
|
parseGitLog(log) {
|
|
481
869
|
return log.trim().split("\n").map((line) => {
|
|
482
870
|
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
@@ -489,7 +877,7 @@ var ReplayDetector = class {
|
|
|
489
877
|
};
|
|
490
878
|
|
|
491
879
|
// src/ThreeWayMerge.ts
|
|
492
|
-
import { diff3Merge } from "node-diff3";
|
|
880
|
+
import { diff3Merge, diffPatch } from "node-diff3";
|
|
493
881
|
function threeWayMerge(base, ours, theirs) {
|
|
494
882
|
const baseLines = base.split("\n");
|
|
495
883
|
const oursLines = ours.split("\n");
|
|
@@ -503,20 +891,33 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
503
891
|
outputLines.push(...region.ok);
|
|
504
892
|
currentLine += region.ok.length;
|
|
505
893
|
} else if (region.conflict) {
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
894
|
+
const resolved = tryResolveConflict(
|
|
895
|
+
region.conflict.a,
|
|
896
|
+
// ours (generator)
|
|
897
|
+
region.conflict.o,
|
|
898
|
+
// base
|
|
899
|
+
region.conflict.b
|
|
900
|
+
// theirs (user)
|
|
901
|
+
);
|
|
902
|
+
if (resolved !== null) {
|
|
903
|
+
outputLines.push(...resolved);
|
|
904
|
+
currentLine += resolved.length;
|
|
905
|
+
} else {
|
|
906
|
+
const startLine = currentLine;
|
|
907
|
+
outputLines.push("<<<<<<< Generated");
|
|
908
|
+
outputLines.push(...region.conflict.a);
|
|
909
|
+
outputLines.push("=======");
|
|
910
|
+
outputLines.push(...region.conflict.b);
|
|
911
|
+
outputLines.push(">>>>>>> Your customization");
|
|
912
|
+
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
913
|
+
conflicts.push({
|
|
914
|
+
startLine,
|
|
915
|
+
endLine: startLine + conflictLines - 1,
|
|
916
|
+
ours: region.conflict.a,
|
|
917
|
+
theirs: region.conflict.b
|
|
918
|
+
});
|
|
919
|
+
currentLine += conflictLines;
|
|
920
|
+
}
|
|
520
921
|
}
|
|
521
922
|
}
|
|
522
923
|
return {
|
|
@@ -525,6 +926,62 @@ function threeWayMerge(base, ours, theirs) {
|
|
|
525
926
|
conflicts
|
|
526
927
|
};
|
|
527
928
|
}
|
|
929
|
+
function tryResolveConflict(oursLines, baseLines, theirsLines) {
|
|
930
|
+
if (baseLines.length === 0) {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
const oursPatches = diffPatch(baseLines, oursLines);
|
|
934
|
+
const theirsPatches = diffPatch(baseLines, theirsLines);
|
|
935
|
+
if (oursPatches.length === 0) return theirsLines;
|
|
936
|
+
if (theirsPatches.length === 0) return oursLines;
|
|
937
|
+
if (patchesOverlap(oursPatches, theirsPatches)) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return applyBothPatches(baseLines, oursPatches, theirsPatches);
|
|
941
|
+
}
|
|
942
|
+
function patchesOverlap(oursPatches, theirsPatches) {
|
|
943
|
+
for (const op of oursPatches) {
|
|
944
|
+
const oStart = op.buffer1.offset;
|
|
945
|
+
const oEnd = oStart + op.buffer1.length;
|
|
946
|
+
for (const tp of theirsPatches) {
|
|
947
|
+
const tStart = tp.buffer1.offset;
|
|
948
|
+
const tEnd = tStart + tp.buffer1.length;
|
|
949
|
+
if (op.buffer1.length === 0 && tp.buffer1.length === 0 && oStart === tStart) {
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
if (tp.buffer1.length === 0 && tStart === oEnd) {
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
if (op.buffer1.length === 0 && oStart === tEnd) {
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
if (oStart < tEnd && tStart < oEnd) {
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
function applyBothPatches(baseLines, oursPatches, theirsPatches) {
|
|
966
|
+
const allPatches = [
|
|
967
|
+
...oursPatches.map((p) => ({
|
|
968
|
+
offset: p.buffer1.offset,
|
|
969
|
+
length: p.buffer1.length,
|
|
970
|
+
replacement: p.buffer2.chunk
|
|
971
|
+
})),
|
|
972
|
+
...theirsPatches.map((p) => ({
|
|
973
|
+
offset: p.buffer1.offset,
|
|
974
|
+
length: p.buffer1.length,
|
|
975
|
+
replacement: p.buffer2.chunk
|
|
976
|
+
}))
|
|
977
|
+
];
|
|
978
|
+
allPatches.sort((a, b) => b.offset - a.offset);
|
|
979
|
+
const result = [...baseLines];
|
|
980
|
+
for (const p of allPatches) {
|
|
981
|
+
result.splice(p.offset, p.length, ...p.replacement);
|
|
982
|
+
}
|
|
983
|
+
return result;
|
|
984
|
+
}
|
|
528
985
|
|
|
529
986
|
// src/ReplayApplicator.ts
|
|
530
987
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
@@ -533,29 +990,74 @@ import { dirname as dirname2, extname, join as join2 } from "path";
|
|
|
533
990
|
import { minimatch } from "minimatch";
|
|
534
991
|
|
|
535
992
|
// src/conflict-utils.ts
|
|
993
|
+
var CONFLICT_OPENER = "<<<<<<< Generated";
|
|
994
|
+
var CONFLICT_SEPARATOR = "=======";
|
|
995
|
+
var CONFLICT_CLOSER = ">>>>>>> Your customization";
|
|
996
|
+
function trimCR(line) {
|
|
997
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
998
|
+
}
|
|
999
|
+
function findConflictRanges(lines) {
|
|
1000
|
+
const ranges = [];
|
|
1001
|
+
let i = 0;
|
|
1002
|
+
while (i < lines.length) {
|
|
1003
|
+
if (trimCR(lines[i]) === CONFLICT_OPENER) {
|
|
1004
|
+
let separatorIdx = -1;
|
|
1005
|
+
let j = i + 1;
|
|
1006
|
+
let found = false;
|
|
1007
|
+
while (j < lines.length) {
|
|
1008
|
+
const trimmed = trimCR(lines[j]);
|
|
1009
|
+
if (trimmed === CONFLICT_OPENER) {
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
if (separatorIdx === -1 && trimmed === CONFLICT_SEPARATOR) {
|
|
1013
|
+
separatorIdx = j;
|
|
1014
|
+
} else if (separatorIdx !== -1 && trimmed === CONFLICT_CLOSER) {
|
|
1015
|
+
ranges.push({ start: i, separator: separatorIdx, end: j });
|
|
1016
|
+
i = j;
|
|
1017
|
+
found = true;
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
j++;
|
|
1021
|
+
}
|
|
1022
|
+
if (!found) {
|
|
1023
|
+
i++;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
i++;
|
|
1028
|
+
}
|
|
1029
|
+
return ranges;
|
|
1030
|
+
}
|
|
536
1031
|
function stripConflictMarkers(content) {
|
|
537
|
-
const lines = content.split(
|
|
1032
|
+
const lines = content.split(/\r?\n/);
|
|
1033
|
+
const ranges = findConflictRanges(lines);
|
|
1034
|
+
if (ranges.length === 0) {
|
|
1035
|
+
return content;
|
|
1036
|
+
}
|
|
538
1037
|
const result = [];
|
|
539
|
-
let
|
|
540
|
-
let
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1038
|
+
let rangeIdx = 0;
|
|
1039
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1040
|
+
if (rangeIdx < ranges.length) {
|
|
1041
|
+
const range = ranges[rangeIdx];
|
|
1042
|
+
if (i === range.start) {
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (i > range.start && i < range.separator) {
|
|
1046
|
+
result.push(lines[i]);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (i === range.separator) {
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
if (i > range.separator && i < range.end) {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (i === range.end) {
|
|
1056
|
+
rangeIdx++;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
558
1059
|
}
|
|
1060
|
+
result.push(lines[i]);
|
|
559
1061
|
}
|
|
560
1062
|
return result.join("\n");
|
|
561
1063
|
}
|
|
@@ -615,12 +1117,34 @@ var ReplayApplicator = class {
|
|
|
615
1117
|
lockManager;
|
|
616
1118
|
outputDir;
|
|
617
1119
|
renameCache = /* @__PURE__ */ new Map();
|
|
1120
|
+
treeExistsCache = /* @__PURE__ */ new Map();
|
|
618
1121
|
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
619
1122
|
constructor(git, lockManager, outputDir) {
|
|
620
1123
|
this.git = git;
|
|
621
1124
|
this.lockManager = lockManager;
|
|
622
1125
|
this.outputDir = outputDir;
|
|
623
1126
|
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Resolve the GenerationRecord for a patch's base_generation.
|
|
1129
|
+
* Falls back to constructing an ad-hoc record from the commit's tree
|
|
1130
|
+
* when base_generation isn't a tracked generation (commit-scan patches).
|
|
1131
|
+
*/
|
|
1132
|
+
async resolveBaseGeneration(baseGeneration) {
|
|
1133
|
+
const gen = this.lockManager.getGeneration(baseGeneration);
|
|
1134
|
+
if (gen) return gen;
|
|
1135
|
+
try {
|
|
1136
|
+
const treeHash = await this.git.getTreeHash(baseGeneration);
|
|
1137
|
+
return {
|
|
1138
|
+
commit_sha: baseGeneration,
|
|
1139
|
+
tree_hash: treeHash,
|
|
1140
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1141
|
+
cli_version: "unknown",
|
|
1142
|
+
generator_versions: {}
|
|
1143
|
+
};
|
|
1144
|
+
} catch {
|
|
1145
|
+
return void 0;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
624
1148
|
/** Reset inter-patch accumulator for a new cycle. */
|
|
625
1149
|
resetAccumulator() {
|
|
626
1150
|
this.fileTheirsAccumulator.clear();
|
|
@@ -702,7 +1226,7 @@ var ReplayApplicator = class {
|
|
|
702
1226
|
}
|
|
703
1227
|
}
|
|
704
1228
|
async applyPatchWithFallback(patch) {
|
|
705
|
-
const baseGen = this.
|
|
1229
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
706
1230
|
const lock = this.lockManager.read();
|
|
707
1231
|
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
708
1232
|
const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
|
|
@@ -786,7 +1310,7 @@ var ReplayApplicator = class {
|
|
|
786
1310
|
}
|
|
787
1311
|
async mergeFile(patch, filePath, tempGit, tempDir) {
|
|
788
1312
|
try {
|
|
789
|
-
const baseGen = this.
|
|
1313
|
+
const baseGen = await this.resolveBaseGeneration(patch.base_generation);
|
|
790
1314
|
if (!baseGen) {
|
|
791
1315
|
return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
|
|
792
1316
|
}
|
|
@@ -811,14 +1335,44 @@ var ReplayApplicator = class {
|
|
|
811
1335
|
}
|
|
812
1336
|
const oursPath = join2(this.outputDir, resolvedPath);
|
|
813
1337
|
const ours = await readFile(oursPath, "utf-8").catch(() => null);
|
|
814
|
-
let
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1338
|
+
let ghostReconstructed = false;
|
|
1339
|
+
let theirs = null;
|
|
1340
|
+
if (!base && ours && !renameSourcePath) {
|
|
1341
|
+
const treeReachable = await this.isTreeReachable(baseGen.tree_hash);
|
|
1342
|
+
if (!treeReachable) {
|
|
1343
|
+
const fileDiff = this.extractFileDiff(patch.patch_content, filePath);
|
|
1344
|
+
if (fileDiff) {
|
|
1345
|
+
const { reconstructFromGhostPatch: reconstructFromGhostPatch2 } = await Promise.resolve().then(() => (init_HybridReconstruction(), HybridReconstruction_exports));
|
|
1346
|
+
const result = reconstructFromGhostPatch2(fileDiff, ours);
|
|
1347
|
+
if (result) {
|
|
1348
|
+
base = result.base;
|
|
1349
|
+
theirs = result.theirs;
|
|
1350
|
+
ghostReconstructed = true;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (!ghostReconstructed) {
|
|
1356
|
+
theirs = await this.applyPatchToContent(
|
|
1357
|
+
base,
|
|
1358
|
+
patch.patch_content,
|
|
1359
|
+
filePath,
|
|
1360
|
+
tempGit,
|
|
1361
|
+
tempDir,
|
|
1362
|
+
renameSourcePath
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
if (theirs) {
|
|
1366
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1367
|
+
const baseHasMarkers = base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization"));
|
|
1368
|
+
if (theirsHasMarkers && !baseHasMarkers) {
|
|
1369
|
+
return {
|
|
1370
|
+
file: resolvedPath,
|
|
1371
|
+
status: "skipped",
|
|
1372
|
+
reason: "stale-conflict-markers"
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
822
1376
|
let useAccumulatorAsMergeBase = false;
|
|
823
1377
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
824
1378
|
if (!theirs && accumulatorEntry) {
|
|
@@ -833,6 +1387,17 @@ var ReplayApplicator = class {
|
|
|
833
1387
|
useAccumulatorAsMergeBase = true;
|
|
834
1388
|
}
|
|
835
1389
|
}
|
|
1390
|
+
if (theirs) {
|
|
1391
|
+
const theirsHasMarkers = theirs.includes("<<<<<<< Generated") || theirs.includes(">>>>>>> Your customization");
|
|
1392
|
+
const accBaseHasMarkers = accumulatorEntry != null && (accumulatorEntry.content.includes("<<<<<<< Generated") || accumulatorEntry.content.includes(">>>>>>> Your customization"));
|
|
1393
|
+
if (theirsHasMarkers && !accBaseHasMarkers && !(base != null && (base.includes("<<<<<<< Generated") || base.includes(">>>>>>> Your customization")))) {
|
|
1394
|
+
return {
|
|
1395
|
+
file: resolvedPath,
|
|
1396
|
+
status: "skipped",
|
|
1397
|
+
reason: "stale-conflict-markers"
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
836
1401
|
let effective_theirs = theirs;
|
|
837
1402
|
let baseMismatchSkipped = false;
|
|
838
1403
|
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
@@ -851,13 +1416,13 @@ var ReplayApplicator = class {
|
|
|
851
1416
|
baseMismatchSkipped = true;
|
|
852
1417
|
}
|
|
853
1418
|
}
|
|
854
|
-
if (
|
|
1419
|
+
if (base == null && !ours && effective_theirs) {
|
|
855
1420
|
const outDir2 = dirname2(oursPath);
|
|
856
1421
|
await mkdir(outDir2, { recursive: true });
|
|
857
1422
|
await writeFile(oursPath, effective_theirs);
|
|
858
1423
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
859
1424
|
}
|
|
860
|
-
if (
|
|
1425
|
+
if (base == null && ours && effective_theirs) {
|
|
861
1426
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
862
1427
|
const outDir2 = dirname2(oursPath);
|
|
863
1428
|
await mkdir(outDir2, { recursive: true });
|
|
@@ -880,7 +1445,7 @@ var ReplayApplicator = class {
|
|
|
880
1445
|
reason: "missing-content"
|
|
881
1446
|
};
|
|
882
1447
|
}
|
|
883
|
-
if (
|
|
1448
|
+
if (base == null && !useAccumulatorAsMergeBase || !ours) {
|
|
884
1449
|
return {
|
|
885
1450
|
file: resolvedPath,
|
|
886
1451
|
status: "skipped",
|
|
@@ -899,7 +1464,7 @@ var ReplayApplicator = class {
|
|
|
899
1464
|
const outDir = dirname2(oursPath);
|
|
900
1465
|
await mkdir(outDir, { recursive: true });
|
|
901
1466
|
await writeFile(oursPath, merged.content);
|
|
902
|
-
if (effective_theirs) {
|
|
1467
|
+
if (effective_theirs && !merged.hasConflicts) {
|
|
903
1468
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
904
1469
|
content: effective_theirs,
|
|
905
1470
|
baseGeneration: patch.base_generation
|
|
@@ -923,6 +1488,14 @@ var ReplayApplicator = class {
|
|
|
923
1488
|
};
|
|
924
1489
|
}
|
|
925
1490
|
}
|
|
1491
|
+
async isTreeReachable(treeHash) {
|
|
1492
|
+
let result = this.treeExistsCache.get(treeHash);
|
|
1493
|
+
if (result === void 0) {
|
|
1494
|
+
result = await this.git.treeExists(treeHash);
|
|
1495
|
+
this.treeExistsCache.set(treeHash, result);
|
|
1496
|
+
}
|
|
1497
|
+
return result;
|
|
1498
|
+
}
|
|
926
1499
|
isExcluded(patch) {
|
|
927
1500
|
const config = this.lockManager.getCustomizationsConfig();
|
|
928
1501
|
if (!config.exclude) return false;
|
|
@@ -1193,6 +1766,7 @@ var ReplayService = class {
|
|
|
1193
1766
|
generator_versions: options?.generatorVersions ?? {},
|
|
1194
1767
|
base_branch_head: options?.baseBranchHead
|
|
1195
1768
|
};
|
|
1769
|
+
let resolvedPatches;
|
|
1196
1770
|
if (!this.lockManager.exists()) {
|
|
1197
1771
|
this.lockManager.initializeInMemory(record);
|
|
1198
1772
|
} else {
|
|
@@ -1201,13 +1775,13 @@ var ReplayService = class {
|
|
|
1201
1775
|
...this.lockManager.getUnresolvedPatches(),
|
|
1202
1776
|
...this.lockManager.getResolvingPatches()
|
|
1203
1777
|
];
|
|
1778
|
+
resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
|
|
1204
1779
|
this.lockManager.addGeneration(record);
|
|
1205
1780
|
this.lockManager.clearPatches();
|
|
1206
1781
|
for (const patch of unresolvedPatches) {
|
|
1207
1782
|
this.lockManager.addPatch(patch);
|
|
1208
1783
|
}
|
|
1209
1784
|
}
|
|
1210
|
-
this.lockManager.save();
|
|
1211
1785
|
try {
|
|
1212
1786
|
const { patches: redetectedPatches } = await this.detector.detectNewPatches();
|
|
1213
1787
|
if (redetectedPatches.length > 0) {
|
|
@@ -1221,20 +1795,27 @@ var ReplayService = class {
|
|
|
1221
1795
|
for (const patch of redetectedPatches) {
|
|
1222
1796
|
this.lockManager.addPatch(patch);
|
|
1223
1797
|
}
|
|
1224
|
-
this.lockManager.save();
|
|
1225
1798
|
}
|
|
1226
|
-
} catch {
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
for (const patch of resolvedPatches ?? []) {
|
|
1801
|
+
this.lockManager.addPatch(patch);
|
|
1802
|
+
}
|
|
1803
|
+
this.detector.warnings.push(
|
|
1804
|
+
`Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
|
|
1805
|
+
);
|
|
1227
1806
|
}
|
|
1807
|
+
this.lockManager.save();
|
|
1228
1808
|
}
|
|
1229
1809
|
determineFlow() {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1810
|
+
try {
|
|
1811
|
+
const lock = this.lockManager.read();
|
|
1812
|
+
return lock.patches.length === 0 ? "no-patches" : "normal-regeneration";
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
if (error instanceof LockfileNotFoundError) {
|
|
1815
|
+
return "first-generation";
|
|
1816
|
+
}
|
|
1817
|
+
throw error;
|
|
1236
1818
|
}
|
|
1237
|
-
return "normal-regeneration";
|
|
1238
1819
|
}
|
|
1239
1820
|
async handleFirstGeneration(options) {
|
|
1240
1821
|
if (options?.dryRun) {
|
|
@@ -1276,12 +1857,17 @@ var ReplayService = class {
|
|
|
1276
1857
|
baseBranchHead: options.baseBranchHead
|
|
1277
1858
|
} : void 0;
|
|
1278
1859
|
await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
1860
|
+
await this.cleanupStaleConflictMarkers();
|
|
1279
1861
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1280
|
-
|
|
1862
|
+
try {
|
|
1281
1863
|
this.lockManager.read();
|
|
1282
1864
|
this.lockManager.addGeneration(genRecord);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
if (error instanceof LockfileNotFoundError) {
|
|
1867
|
+
this.lockManager.initializeInMemory(genRecord);
|
|
1868
|
+
} else {
|
|
1869
|
+
throw error;
|
|
1870
|
+
}
|
|
1285
1871
|
}
|
|
1286
1872
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
1287
1873
|
this.lockManager.save();
|
|
@@ -1327,6 +1913,7 @@ var ReplayService = class {
|
|
|
1327
1913
|
baseBranchHead: options.baseBranchHead
|
|
1328
1914
|
} : void 0;
|
|
1329
1915
|
await this.committer.commitGeneration("Update SDK", commitOpts);
|
|
1916
|
+
await this.cleanupStaleConflictMarkers();
|
|
1330
1917
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1331
1918
|
this.lockManager.addGeneration(genRecord);
|
|
1332
1919
|
let results = [];
|
|
@@ -1420,6 +2007,7 @@ var ReplayService = class {
|
|
|
1420
2007
|
baseBranchHead: options.baseBranchHead
|
|
1421
2008
|
} : void 0;
|
|
1422
2009
|
await this.committer.commitGeneration("Update SDK", commitOpts);
|
|
2010
|
+
await this.cleanupStaleConflictMarkers();
|
|
1423
2011
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1424
2012
|
this.lockManager.addGeneration(genRecord);
|
|
1425
2013
|
const results = await this.applicator.applyPatches(allPatches);
|
|
@@ -1610,6 +2198,13 @@ var ReplayService = class {
|
|
|
1610
2198
|
contentRefreshed++;
|
|
1611
2199
|
continue;
|
|
1612
2200
|
}
|
|
2201
|
+
const diffLines = diff.split("\n");
|
|
2202
|
+
const hasStaleMarkers = diffLines.some(
|
|
2203
|
+
(l) => l.startsWith("+<<<<<<< Generated") || l.startsWith("+>>>>>>> Your customization")
|
|
2204
|
+
);
|
|
2205
|
+
if (hasStaleMarkers) {
|
|
2206
|
+
continue;
|
|
2207
|
+
}
|
|
1613
2208
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
1614
2209
|
if (newContentHash !== patch.content_hash) {
|
|
1615
2210
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -1630,7 +2225,7 @@ var ReplayService = class {
|
|
|
1630
2225
|
continue;
|
|
1631
2226
|
}
|
|
1632
2227
|
try {
|
|
1633
|
-
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
2228
|
+
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
1634
2229
|
if (markerFiles.trim()) continue;
|
|
1635
2230
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
1636
2231
|
if (diff === null) continue;
|
|
@@ -1670,6 +2265,25 @@ var ReplayService = class {
|
|
|
1670
2265
|
}
|
|
1671
2266
|
}
|
|
1672
2267
|
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Clean up stale conflict markers left by a previous crashed run.
|
|
2270
|
+
* Called after commitGeneration() when HEAD is the [fern-generated] commit.
|
|
2271
|
+
* Restores files to their clean generated state from HEAD.
|
|
2272
|
+
* Skips .fernignore-protected files to prevent overwriting user content.
|
|
2273
|
+
*/
|
|
2274
|
+
async cleanupStaleConflictMarkers() {
|
|
2275
|
+
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<< Generated", "--", "."]).catch(() => "");
|
|
2276
|
+
const files = markerFiles.trim().split("\n").filter(Boolean);
|
|
2277
|
+
if (files.length === 0) return;
|
|
2278
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
2279
|
+
for (const file of files) {
|
|
2280
|
+
if (fernignorePatterns.some((pattern) => minimatch2(file, pattern))) continue;
|
|
2281
|
+
try {
|
|
2282
|
+
await this.git.exec(["checkout", "HEAD", "--", file]);
|
|
2283
|
+
} catch {
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
1673
2287
|
readFernignorePatterns() {
|
|
1674
2288
|
const fernignorePath = join3(this.outputDir, ".fernignore");
|
|
1675
2289
|
if (!existsSync2(fernignorePath)) return [];
|
|
@@ -1997,6 +2611,7 @@ async function bootstrap(outputDir, options) {
|
|
|
1997
2611
|
}
|
|
1998
2612
|
lockManager.save();
|
|
1999
2613
|
const fernignoreUpdated = ensureFernignoreEntries(outputDir);
|
|
2614
|
+
ensureGitattributesEntries(outputDir);
|
|
2000
2615
|
if (migrator.fernignoreExists() && fernignorePatterns.length > 0) {
|
|
2001
2616
|
const action = options?.fernignoreAction ?? "skip";
|
|
2002
2617
|
if (action === "migrate") {
|
|
@@ -2078,7 +2693,7 @@ function parseGitLog(log) {
|
|
|
2078
2693
|
return { sha, authorName, authorEmail, message };
|
|
2079
2694
|
});
|
|
2080
2695
|
}
|
|
2081
|
-
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml"];
|
|
2696
|
+
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
|
|
2082
2697
|
function ensureFernignoreEntries(outputDir) {
|
|
2083
2698
|
const fernignorePath = join5(outputDir, ".fernignore");
|
|
2084
2699
|
let content = "";
|
|
@@ -2102,6 +2717,29 @@ function ensureFernignoreEntries(outputDir) {
|
|
|
2102
2717
|
writeFileSync4(fernignorePath, content, "utf-8");
|
|
2103
2718
|
return true;
|
|
2104
2719
|
}
|
|
2720
|
+
var GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
|
|
2721
|
+
function ensureGitattributesEntries(outputDir) {
|
|
2722
|
+
const gitattributesPath = join5(outputDir, ".gitattributes");
|
|
2723
|
+
let content = "";
|
|
2724
|
+
if (existsSync4(gitattributesPath)) {
|
|
2725
|
+
content = readFileSync4(gitattributesPath, "utf-8");
|
|
2726
|
+
}
|
|
2727
|
+
const lines = content.split("\n");
|
|
2728
|
+
const toAdd = [];
|
|
2729
|
+
for (const entry of GITATTRIBUTES_ENTRIES) {
|
|
2730
|
+
if (!lines.some((line) => line.trim() === entry)) {
|
|
2731
|
+
toAdd.push(entry);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
if (toAdd.length === 0) {
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
if (content && !content.endsWith("\n")) {
|
|
2738
|
+
content += "\n";
|
|
2739
|
+
}
|
|
2740
|
+
content += toAdd.join("\n") + "\n";
|
|
2741
|
+
writeFileSync4(gitattributesPath, content, "utf-8");
|
|
2742
|
+
}
|
|
2105
2743
|
function computeContentHash(patchContent) {
|
|
2106
2744
|
const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
|
|
2107
2745
|
return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
|
|
@@ -2420,6 +3058,7 @@ export {
|
|
|
2420
3058
|
FernignoreMigrator,
|
|
2421
3059
|
GitClient,
|
|
2422
3060
|
LockfileManager,
|
|
3061
|
+
LockfileNotFoundError,
|
|
2423
3062
|
ReplayApplicator,
|
|
2424
3063
|
ReplayCommitter,
|
|
2425
3064
|
ReplayDetector,
|