@fern-api/replay 0.10.4 → 0.12.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 +928 -100
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +820 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +277 -11
- package/dist/index.d.ts +277 -11
- package/dist/index.js +814 -99
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/cli.cjs
CHANGED
|
@@ -499,6 +499,121 @@ var require_browser = __commonJS({
|
|
|
499
499
|
}
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
+
// node_modules/has-flag/index.js
|
|
503
|
+
var require_has_flag = __commonJS({
|
|
504
|
+
"node_modules/has-flag/index.js"(exports2, module2) {
|
|
505
|
+
"use strict";
|
|
506
|
+
module2.exports = (flag, argv = process.argv) => {
|
|
507
|
+
const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
|
|
508
|
+
const position = argv.indexOf(prefix + flag);
|
|
509
|
+
const terminatorPosition = argv.indexOf("--");
|
|
510
|
+
return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// node_modules/supports-color/index.js
|
|
516
|
+
var require_supports_color = __commonJS({
|
|
517
|
+
"node_modules/supports-color/index.js"(exports2, module2) {
|
|
518
|
+
"use strict";
|
|
519
|
+
var os = require("os");
|
|
520
|
+
var tty = require("tty");
|
|
521
|
+
var hasFlag = require_has_flag();
|
|
522
|
+
var { env } = process;
|
|
523
|
+
var forceColor;
|
|
524
|
+
if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
|
|
525
|
+
forceColor = 0;
|
|
526
|
+
} else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
|
|
527
|
+
forceColor = 1;
|
|
528
|
+
}
|
|
529
|
+
if ("FORCE_COLOR" in env) {
|
|
530
|
+
if (env.FORCE_COLOR === "true") {
|
|
531
|
+
forceColor = 1;
|
|
532
|
+
} else if (env.FORCE_COLOR === "false") {
|
|
533
|
+
forceColor = 0;
|
|
534
|
+
} else {
|
|
535
|
+
forceColor = env.FORCE_COLOR.length === 0 ? 1 : Math.min(parseInt(env.FORCE_COLOR, 10), 3);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function translateLevel(level) {
|
|
539
|
+
if (level === 0) {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
level,
|
|
544
|
+
hasBasic: true,
|
|
545
|
+
has256: level >= 2,
|
|
546
|
+
has16m: level >= 3
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function supportsColor(haveStream, streamIsTTY) {
|
|
550
|
+
if (forceColor === 0) {
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
|
|
554
|
+
return 3;
|
|
555
|
+
}
|
|
556
|
+
if (hasFlag("color=256")) {
|
|
557
|
+
return 2;
|
|
558
|
+
}
|
|
559
|
+
if (haveStream && !streamIsTTY && forceColor === void 0) {
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
const min = forceColor || 0;
|
|
563
|
+
if (env.TERM === "dumb") {
|
|
564
|
+
return min;
|
|
565
|
+
}
|
|
566
|
+
if (process.platform === "win32") {
|
|
567
|
+
const osRelease = os.release().split(".");
|
|
568
|
+
if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
|
|
569
|
+
return Number(osRelease[2]) >= 14931 ? 3 : 2;
|
|
570
|
+
}
|
|
571
|
+
return 1;
|
|
572
|
+
}
|
|
573
|
+
if ("CI" in env) {
|
|
574
|
+
if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE"].some((sign) => sign in env) || env.CI_NAME === "codeship") {
|
|
575
|
+
return 1;
|
|
576
|
+
}
|
|
577
|
+
return min;
|
|
578
|
+
}
|
|
579
|
+
if ("TEAMCITY_VERSION" in env) {
|
|
580
|
+
return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
|
|
581
|
+
}
|
|
582
|
+
if (env.COLORTERM === "truecolor") {
|
|
583
|
+
return 3;
|
|
584
|
+
}
|
|
585
|
+
if ("TERM_PROGRAM" in env) {
|
|
586
|
+
const version = parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
|
|
587
|
+
switch (env.TERM_PROGRAM) {
|
|
588
|
+
case "iTerm.app":
|
|
589
|
+
return version >= 3 ? 3 : 2;
|
|
590
|
+
case "Apple_Terminal":
|
|
591
|
+
return 2;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (/-256(color)?$/i.test(env.TERM)) {
|
|
595
|
+
return 2;
|
|
596
|
+
}
|
|
597
|
+
if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
|
|
598
|
+
return 1;
|
|
599
|
+
}
|
|
600
|
+
if ("COLORTERM" in env) {
|
|
601
|
+
return 1;
|
|
602
|
+
}
|
|
603
|
+
return min;
|
|
604
|
+
}
|
|
605
|
+
function getSupportLevel(stream) {
|
|
606
|
+
const level = supportsColor(stream, stream && stream.isTTY);
|
|
607
|
+
return translateLevel(level);
|
|
608
|
+
}
|
|
609
|
+
module2.exports = {
|
|
610
|
+
supportsColor: getSupportLevel,
|
|
611
|
+
stdout: translateLevel(supportsColor(true, tty.isatty(1))),
|
|
612
|
+
stderr: translateLevel(supportsColor(true, tty.isatty(2)))
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
502
617
|
// node_modules/debug/src/node.js
|
|
503
618
|
var require_node = __commonJS({
|
|
504
619
|
"node_modules/debug/src/node.js"(exports2, module2) {
|
|
@@ -518,7 +633,7 @@ var require_node = __commonJS({
|
|
|
518
633
|
);
|
|
519
634
|
exports2.colors = [6, 2, 3, 4, 5, 1];
|
|
520
635
|
try {
|
|
521
|
-
const supportsColor =
|
|
636
|
+
const supportsColor = require_supports_color();
|
|
522
637
|
if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) {
|
|
523
638
|
exports2.colors = [
|
|
524
639
|
20,
|
|
@@ -5301,13 +5416,30 @@ var init_GitClient = __esm({
|
|
|
5301
5416
|
const output = await this.exec(["rev-parse", `${commitSha}^@`]);
|
|
5302
5417
|
return output.trim().split("\n").filter(Boolean);
|
|
5303
5418
|
}
|
|
5419
|
+
/**
|
|
5420
|
+
* Detects renames and copies between two trees.
|
|
5421
|
+
*
|
|
5422
|
+
* Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
|
|
5423
|
+
* Copies are treated as rename-equivalent because the generator may produce the
|
|
5424
|
+
* new path while the old path remains in the tree (e.g., imperfect cleanup,
|
|
5425
|
+
* customer edits at the old path, or test helpers that only write new files).
|
|
5426
|
+
* Callers that care about "is the source still present" should verify via
|
|
5427
|
+
* `showFile(toTree, from)`.
|
|
5428
|
+
*/
|
|
5304
5429
|
async detectRenames(fromTree, toTree) {
|
|
5305
5430
|
try {
|
|
5306
|
-
const output = await this.exec([
|
|
5431
|
+
const output = await this.exec([
|
|
5432
|
+
"diff",
|
|
5433
|
+
"--find-renames",
|
|
5434
|
+
"--find-copies",
|
|
5435
|
+
"--name-status",
|
|
5436
|
+
fromTree,
|
|
5437
|
+
toTree
|
|
5438
|
+
]);
|
|
5307
5439
|
const renames = [];
|
|
5308
5440
|
for (const line of output.trim().split("\n")) {
|
|
5309
5441
|
if (!line) continue;
|
|
5310
|
-
if (line.startsWith("R")) {
|
|
5442
|
+
if (line.startsWith("R") || line.startsWith("C")) {
|
|
5311
5443
|
const parts = line.split(" ");
|
|
5312
5444
|
if (parts.length >= 3) {
|
|
5313
5445
|
renames.push({ from: parts[1], to: parts[2] });
|
|
@@ -13110,6 +13242,41 @@ init_GitClient();
|
|
|
13110
13242
|
var import_node_fs = require("fs");
|
|
13111
13243
|
var import_node_path2 = require("path");
|
|
13112
13244
|
var import_yaml = __toESM(require_dist3(), 1);
|
|
13245
|
+
|
|
13246
|
+
// src/credentials.ts
|
|
13247
|
+
var CREDENTIAL_PATTERNS = [
|
|
13248
|
+
{ name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
13249
|
+
{ name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
|
|
13250
|
+
{ name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
|
|
13251
|
+
{ name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
|
|
13252
|
+
{ name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
|
|
13253
|
+
{ name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
|
|
13254
|
+
{ name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
|
|
13255
|
+
{ name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
|
|
13256
|
+
{ name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
|
|
13257
|
+
];
|
|
13258
|
+
var SENSITIVE_FILE_PATTERNS = [
|
|
13259
|
+
/(^|\/)\.env(\.[^/]+)?$/,
|
|
13260
|
+
/\.pem$/,
|
|
13261
|
+
/\.key$/,
|
|
13262
|
+
/\.pkce_state\.json$/,
|
|
13263
|
+
/(^|\/)id_rsa$/,
|
|
13264
|
+
/(^|\/)id_ed25519$/
|
|
13265
|
+
];
|
|
13266
|
+
function isSensitiveFile(filePath) {
|
|
13267
|
+
return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
|
|
13268
|
+
}
|
|
13269
|
+
function scanForCredentials(content) {
|
|
13270
|
+
const matches = [];
|
|
13271
|
+
for (const { name, re } of CREDENTIAL_PATTERNS) {
|
|
13272
|
+
if (re.test(content)) {
|
|
13273
|
+
matches.push(name);
|
|
13274
|
+
}
|
|
13275
|
+
}
|
|
13276
|
+
return matches;
|
|
13277
|
+
}
|
|
13278
|
+
|
|
13279
|
+
// src/LockfileManager.ts
|
|
13113
13280
|
var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
|
|
13114
13281
|
var LockfileNotFoundError = class extends Error {
|
|
13115
13282
|
constructor(path2) {
|
|
@@ -13120,6 +13287,7 @@ var LockfileNotFoundError = class extends Error {
|
|
|
13120
13287
|
var LockfileManager = class {
|
|
13121
13288
|
outputDir;
|
|
13122
13289
|
lock = null;
|
|
13290
|
+
warnings = [];
|
|
13123
13291
|
constructor(outputDir) {
|
|
13124
13292
|
this.outputDir = outputDir;
|
|
13125
13293
|
}
|
|
@@ -13168,6 +13336,27 @@ var LockfileManager = class {
|
|
|
13168
13336
|
if (!this.lock) {
|
|
13169
13337
|
throw new Error("No lockfile data to save. Call read() or initialize() first.");
|
|
13170
13338
|
}
|
|
13339
|
+
this.warnings.length = 0;
|
|
13340
|
+
const cleanPatches = [];
|
|
13341
|
+
for (const patch of this.lock.patches) {
|
|
13342
|
+
const credentialMatches = scanForCredentials(patch.patch_content);
|
|
13343
|
+
const sensitiveFiles = patch.files.filter((f) => isSensitiveFile(f));
|
|
13344
|
+
if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
|
|
13345
|
+
const reasons = [];
|
|
13346
|
+
if (credentialMatches.length > 0) {
|
|
13347
|
+
reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
|
|
13348
|
+
}
|
|
13349
|
+
if (sensitiveFiles.length > 0) {
|
|
13350
|
+
reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
|
|
13351
|
+
}
|
|
13352
|
+
this.warnings.push(
|
|
13353
|
+
`Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
|
|
13354
|
+
);
|
|
13355
|
+
} else {
|
|
13356
|
+
cleanPatches.push(patch);
|
|
13357
|
+
}
|
|
13358
|
+
}
|
|
13359
|
+
this.lock.patches = cleanPatches;
|
|
13171
13360
|
const dir = (0, import_node_path2.dirname)(this.lockfilePath);
|
|
13172
13361
|
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
13173
13362
|
(0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
@@ -13280,7 +13469,7 @@ var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
|
|
|
13280
13469
|
function isGenerationCommit(commit) {
|
|
13281
13470
|
const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
|
|
13282
13471
|
const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
|
|
13283
|
-
const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
|
|
13472
|
+
const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.startsWith("[fern-autoversion]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
|
|
13284
13473
|
// The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
|
|
13285
13474
|
// GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
|
|
13286
13475
|
commit.message.startsWith("SDK Generation");
|
|
@@ -13303,6 +13492,37 @@ function parseRevertedMessage(subject) {
|
|
|
13303
13492
|
|
|
13304
13493
|
// src/ReplayDetector.ts
|
|
13305
13494
|
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
13495
|
+
function parsePatchFileHeaders(patchContent) {
|
|
13496
|
+
const results = [];
|
|
13497
|
+
let currentPath = null;
|
|
13498
|
+
let currentIsCreate = false;
|
|
13499
|
+
let currentIsDelete = false;
|
|
13500
|
+
const flush = () => {
|
|
13501
|
+
if (currentPath !== null) {
|
|
13502
|
+
results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
|
|
13503
|
+
}
|
|
13504
|
+
};
|
|
13505
|
+
for (const line of patchContent.split("\n")) {
|
|
13506
|
+
if (line.startsWith("diff --git ")) {
|
|
13507
|
+
flush();
|
|
13508
|
+
currentPath = null;
|
|
13509
|
+
currentIsCreate = false;
|
|
13510
|
+
currentIsDelete = false;
|
|
13511
|
+
const match2 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
13512
|
+
if (match2) {
|
|
13513
|
+
currentPath = match2[2] ?? null;
|
|
13514
|
+
}
|
|
13515
|
+
} else if (currentPath !== null) {
|
|
13516
|
+
if (line.startsWith("new file mode ")) {
|
|
13517
|
+
currentIsCreate = true;
|
|
13518
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
13519
|
+
currentIsDelete = true;
|
|
13520
|
+
}
|
|
13521
|
+
}
|
|
13522
|
+
}
|
|
13523
|
+
flush();
|
|
13524
|
+
return results;
|
|
13525
|
+
}
|
|
13306
13526
|
var ReplayDetector = class {
|
|
13307
13527
|
git;
|
|
13308
13528
|
lockManager;
|
|
@@ -13381,12 +13601,31 @@ var ReplayDetector = class {
|
|
|
13381
13601
|
);
|
|
13382
13602
|
continue;
|
|
13383
13603
|
}
|
|
13384
|
-
|
|
13604
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
13385
13605
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
13386
13606
|
continue;
|
|
13387
13607
|
}
|
|
13388
13608
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
13389
|
-
const
|
|
13609
|
+
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
13610
|
+
const sensitiveFiles = allFiles.filter((f) => isSensitiveFile(f));
|
|
13611
|
+
const files = allFiles.filter((f) => !isSensitiveFile(f));
|
|
13612
|
+
if (sensitiveFiles.length > 0) {
|
|
13613
|
+
this.warnings.push(
|
|
13614
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
13615
|
+
);
|
|
13616
|
+
if (files.length > 0) {
|
|
13617
|
+
const parentSha = parents[0];
|
|
13618
|
+
if (parentSha) {
|
|
13619
|
+
try {
|
|
13620
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
13621
|
+
} catch {
|
|
13622
|
+
continue;
|
|
13623
|
+
}
|
|
13624
|
+
if (!patchContent.trim()) continue;
|
|
13625
|
+
contentHash = this.computeContentHash(patchContent);
|
|
13626
|
+
}
|
|
13627
|
+
}
|
|
13628
|
+
}
|
|
13390
13629
|
if (files.length === 0) {
|
|
13391
13630
|
continue;
|
|
13392
13631
|
}
|
|
@@ -13398,14 +13637,16 @@ var ReplayDetector = class {
|
|
|
13398
13637
|
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
13399
13638
|
base_generation: lastGen.commit_sha,
|
|
13400
13639
|
files,
|
|
13401
|
-
patch_content: patchContent
|
|
13640
|
+
patch_content: patchContent,
|
|
13641
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
13402
13642
|
});
|
|
13403
13643
|
}
|
|
13404
|
-
|
|
13644
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
13645
|
+
survivingPatches.reverse();
|
|
13405
13646
|
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
13406
13647
|
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
13407
|
-
for (let i = 0; i <
|
|
13408
|
-
const patch =
|
|
13648
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
13649
|
+
const patch = survivingPatches[i];
|
|
13409
13650
|
if (!isRevertCommit(patch.original_message)) continue;
|
|
13410
13651
|
let body = "";
|
|
13411
13652
|
try {
|
|
@@ -13434,7 +13675,7 @@ var ReplayDetector = class {
|
|
|
13434
13675
|
if (matchedExisting) continue;
|
|
13435
13676
|
let matchedNew = false;
|
|
13436
13677
|
if (revertedSha) {
|
|
13437
|
-
const idx =
|
|
13678
|
+
const idx = survivingPatches.findIndex(
|
|
13438
13679
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
13439
13680
|
);
|
|
13440
13681
|
if (idx !== -1) {
|
|
@@ -13444,7 +13685,7 @@ var ReplayDetector = class {
|
|
|
13444
13685
|
}
|
|
13445
13686
|
}
|
|
13446
13687
|
if (!matchedNew && revertedMessage) {
|
|
13447
|
-
const idx =
|
|
13688
|
+
const idx = survivingPatches.findIndex(
|
|
13448
13689
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
13449
13690
|
);
|
|
13450
13691
|
if (idx !== -1) {
|
|
@@ -13456,18 +13697,107 @@ var ReplayDetector = class {
|
|
|
13456
13697
|
revertIndicesToRemove.add(i);
|
|
13457
13698
|
}
|
|
13458
13699
|
}
|
|
13459
|
-
const filteredPatches =
|
|
13700
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
13460
13701
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
13461
13702
|
}
|
|
13462
13703
|
/**
|
|
13463
13704
|
* Compute content hash for deduplication.
|
|
13464
|
-
*
|
|
13465
|
-
*
|
|
13705
|
+
*
|
|
13706
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
13707
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
13708
|
+
* when the underlying diff hunks are identical.
|
|
13709
|
+
*
|
|
13710
|
+
* Normalization:
|
|
13711
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
13712
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
13713
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
13714
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
13715
|
+
*
|
|
13716
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
13717
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
13718
|
+
* respectively (FER-9850, D5-D9).
|
|
13466
13719
|
*/
|
|
13467
13720
|
computeContentHash(patchContent) {
|
|
13468
|
-
const
|
|
13721
|
+
const lines = patchContent.split("\n");
|
|
13722
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
13723
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
13724
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
13469
13725
|
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
13470
13726
|
}
|
|
13727
|
+
/**
|
|
13728
|
+
* FER-9805 — Drop patches whose files were transiently created and then
|
|
13729
|
+
* deleted within the current linear detection window, and are absent from
|
|
13730
|
+
* HEAD at the end of the window.
|
|
13731
|
+
*
|
|
13732
|
+
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
13733
|
+
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
13734
|
+
* linear history each commit becomes its own patch, so a file that was
|
|
13735
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
13736
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
13737
|
+
* lockfile.
|
|
13738
|
+
*
|
|
13739
|
+
* A file is "transient" when:
|
|
13740
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
13741
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
13742
|
+
* 3. it is absent from HEAD's tree.
|
|
13743
|
+
*
|
|
13744
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
13745
|
+
* and then recreated with different content — the cumulative diff of
|
|
13746
|
+
* such a pair is non-empty and must be preserved.
|
|
13747
|
+
*
|
|
13748
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
13749
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
13750
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
13751
|
+
* single files from a multi-file patch would require a follow-up that
|
|
13752
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
13753
|
+
* current failing test exercises this, and leaving the patch intact
|
|
13754
|
+
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
13755
|
+
* adversarial test demands per-file stripping.
|
|
13756
|
+
*/
|
|
13757
|
+
async collapseNetZeroFiles(patches) {
|
|
13758
|
+
if (patches.length === 0) return patches;
|
|
13759
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
13760
|
+
for (const patch of patches) {
|
|
13761
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
13762
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
13763
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
13764
|
+
if (header.isCreate) prior.created = true;
|
|
13765
|
+
if (header.isDelete) prior.deleted = true;
|
|
13766
|
+
stateByFile.set(header.path, prior);
|
|
13767
|
+
}
|
|
13768
|
+
}
|
|
13769
|
+
let anyCandidate = false;
|
|
13770
|
+
for (const state of stateByFile.values()) {
|
|
13771
|
+
if (state.created && state.deleted) {
|
|
13772
|
+
anyCandidate = true;
|
|
13773
|
+
break;
|
|
13774
|
+
}
|
|
13775
|
+
}
|
|
13776
|
+
if (!anyCandidate) return patches;
|
|
13777
|
+
const headFiles = await this.listHeadFiles();
|
|
13778
|
+
const transient = /* @__PURE__ */ new Set();
|
|
13779
|
+
for (const [file, state] of stateByFile) {
|
|
13780
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
13781
|
+
transient.add(file);
|
|
13782
|
+
}
|
|
13783
|
+
}
|
|
13784
|
+
if (transient.size === 0) return patches;
|
|
13785
|
+
return patches.filter((patch) => !patch.files.every((f) => transient.has(f)));
|
|
13786
|
+
}
|
|
13787
|
+
/**
|
|
13788
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
13789
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
13790
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
13791
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
13792
|
+
*/
|
|
13793
|
+
async listHeadFiles() {
|
|
13794
|
+
try {
|
|
13795
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
13796
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
13797
|
+
} catch {
|
|
13798
|
+
return /* @__PURE__ */ new Set();
|
|
13799
|
+
}
|
|
13800
|
+
}
|
|
13471
13801
|
/**
|
|
13472
13802
|
* Create a single composite patch for a merge commit by diffing the merge
|
|
13473
13803
|
* commit against its first parent. This captures the net change of the
|
|
@@ -13483,7 +13813,14 @@ var ReplayDetector = class {
|
|
|
13483
13813
|
firstParent,
|
|
13484
13814
|
mergeCommit.sha
|
|
13485
13815
|
]);
|
|
13486
|
-
const
|
|
13816
|
+
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
13817
|
+
const sensitiveMergeFiles = allMergeFiles.filter((f) => isSensitiveFile(f));
|
|
13818
|
+
const files = allMergeFiles.filter((f) => !isSensitiveFile(f));
|
|
13819
|
+
if (sensitiveMergeFiles.length > 0) {
|
|
13820
|
+
this.warnings.push(
|
|
13821
|
+
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
13822
|
+
);
|
|
13823
|
+
}
|
|
13487
13824
|
if (files.length === 0) return null;
|
|
13488
13825
|
const diff = await this.git.exec([
|
|
13489
13826
|
"diff",
|
|
@@ -13505,7 +13842,8 @@ var ReplayDetector = class {
|
|
|
13505
13842
|
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
13506
13843
|
base_generation: lastGen.commit_sha,
|
|
13507
13844
|
files,
|
|
13508
|
-
patch_content: diff
|
|
13845
|
+
patch_content: diff,
|
|
13846
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
13509
13847
|
};
|
|
13510
13848
|
}
|
|
13511
13849
|
/**
|
|
@@ -13519,7 +13857,14 @@ var ReplayDetector = class {
|
|
|
13519
13857
|
return this.detectPatchesViaCommitScan();
|
|
13520
13858
|
}
|
|
13521
13859
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
13522
|
-
const
|
|
13860
|
+
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
13861
|
+
const sensitiveTreeFiles = allTreeFiles.filter((f) => isSensitiveFile(f));
|
|
13862
|
+
const files = allTreeFiles.filter((f) => !isSensitiveFile(f));
|
|
13863
|
+
if (sensitiveTreeFiles.length > 0) {
|
|
13864
|
+
this.warnings.push(
|
|
13865
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
13866
|
+
);
|
|
13867
|
+
}
|
|
13523
13868
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
13524
13869
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
13525
13870
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -13539,7 +13884,8 @@ var ReplayDetector = class {
|
|
|
13539
13884
|
// reference to find base file content. diffBase may be the tree_hash.
|
|
13540
13885
|
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
13541
13886
|
files,
|
|
13542
|
-
patch_content: diff
|
|
13887
|
+
patch_content: diff,
|
|
13888
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
13543
13889
|
};
|
|
13544
13890
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
13545
13891
|
}
|
|
@@ -13586,7 +13932,7 @@ var ReplayDetector = class {
|
|
|
13586
13932
|
} catch {
|
|
13587
13933
|
continue;
|
|
13588
13934
|
}
|
|
13589
|
-
|
|
13935
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
13590
13936
|
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
13591
13937
|
continue;
|
|
13592
13938
|
}
|
|
@@ -13594,7 +13940,26 @@ var ReplayDetector = class {
|
|
|
13594
13940
|
continue;
|
|
13595
13941
|
}
|
|
13596
13942
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
13597
|
-
const
|
|
13943
|
+
const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
13944
|
+
const sensitiveScanFiles = allScanFiles.filter((f) => isSensitiveFile(f));
|
|
13945
|
+
const files = allScanFiles.filter((f) => !isSensitiveFile(f));
|
|
13946
|
+
if (sensitiveScanFiles.length > 0) {
|
|
13947
|
+
this.warnings.push(
|
|
13948
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
|
|
13949
|
+
);
|
|
13950
|
+
if (files.length > 0) {
|
|
13951
|
+
if (parents.length === 0) {
|
|
13952
|
+
continue;
|
|
13953
|
+
}
|
|
13954
|
+
try {
|
|
13955
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
13956
|
+
} catch {
|
|
13957
|
+
continue;
|
|
13958
|
+
}
|
|
13959
|
+
if (!patchContent.trim()) continue;
|
|
13960
|
+
contentHash = this.computeContentHash(patchContent);
|
|
13961
|
+
}
|
|
13962
|
+
}
|
|
13598
13963
|
if (files.length === 0) {
|
|
13599
13964
|
continue;
|
|
13600
13965
|
}
|
|
@@ -15506,6 +15871,20 @@ var ReplayApplicator = class {
|
|
|
15506
15871
|
resetAccumulator() {
|
|
15507
15872
|
this.fileTheirsAccumulator.clear();
|
|
15508
15873
|
}
|
|
15874
|
+
/**
|
|
15875
|
+
* @internal Test-only.
|
|
15876
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
15877
|
+
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
15878
|
+
* applied patch, the accumulator has an entry for each file the patch
|
|
15879
|
+
* touched, and the entry's content matches on-disk.
|
|
15880
|
+
*/
|
|
15881
|
+
getAccumulatorSnapshot() {
|
|
15882
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
15883
|
+
for (const [path2, entry] of this.fileTheirsAccumulator) {
|
|
15884
|
+
snapshot.set(path2, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
15885
|
+
}
|
|
15886
|
+
return snapshot;
|
|
15887
|
+
}
|
|
15509
15888
|
/**
|
|
15510
15889
|
* Apply all patches, returning results for each.
|
|
15511
15890
|
* Skips patches that match exclude patterns in replay.yml
|
|
@@ -15571,7 +15950,7 @@ var ReplayApplicator = class {
|
|
|
15571
15950
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
15572
15951
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
15573
15952
|
const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path3.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
15574
|
-
if (effectiveTheirs) {
|
|
15953
|
+
if (effectiveTheirs != null) {
|
|
15575
15954
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
15576
15955
|
content: effectiveTheirs,
|
|
15577
15956
|
baseGeneration: patch.base_generation
|
|
@@ -15595,16 +15974,20 @@ var ReplayApplicator = class {
|
|
|
15595
15974
|
return this.fileTheirsAccumulator.has(resolved);
|
|
15596
15975
|
})
|
|
15597
15976
|
).then((results) => results.some(Boolean));
|
|
15598
|
-
|
|
15977
|
+
const resolvedFiles = {};
|
|
15978
|
+
for (const filePath of patch.files) {
|
|
15979
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
15980
|
+
if (resolvedPath !== filePath) {
|
|
15981
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
15982
|
+
}
|
|
15983
|
+
}
|
|
15984
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
15985
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
15986
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
15599
15987
|
const snapshots = /* @__PURE__ */ new Map();
|
|
15600
|
-
const resolvedFiles = {};
|
|
15601
15988
|
for (const filePath of patch.files) {
|
|
15602
|
-
const
|
|
15603
|
-
|
|
15604
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
15605
|
-
}
|
|
15606
|
-
const fullPath = (0, import_node_path3.join)(this.outputDir, resolvedPath);
|
|
15607
|
-
snapshots.set(resolvedPath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
|
|
15989
|
+
const fullPath = (0, import_node_path3.join)(this.outputDir, filePath);
|
|
15990
|
+
snapshots.set(filePath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
|
|
15608
15991
|
}
|
|
15609
15992
|
try {
|
|
15610
15993
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
@@ -15616,8 +15999,8 @@ var ReplayApplicator = class {
|
|
|
15616
15999
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
15617
16000
|
};
|
|
15618
16001
|
} catch {
|
|
15619
|
-
for (const [
|
|
15620
|
-
const fullPath = (0, import_node_path3.join)(this.outputDir,
|
|
16002
|
+
for (const [filePath, content] of snapshots) {
|
|
16003
|
+
const fullPath = (0, import_node_path3.join)(this.outputDir, filePath);
|
|
15621
16004
|
if (content != null) {
|
|
15622
16005
|
await (0, import_promises.writeFile)(fullPath, content);
|
|
15623
16006
|
} else {
|
|
@@ -15737,7 +16120,7 @@ var ReplayApplicator = class {
|
|
|
15737
16120
|
}
|
|
15738
16121
|
let useAccumulatorAsMergeBase = false;
|
|
15739
16122
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
15740
|
-
if (accumulatorEntry && (
|
|
16123
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
15741
16124
|
theirs = await this.applyPatchToContent(
|
|
15742
16125
|
accumulatorEntry.content,
|
|
15743
16126
|
patch.patch_content,
|
|
@@ -15745,7 +16128,16 @@ var ReplayApplicator = class {
|
|
|
15745
16128
|
tempGit,
|
|
15746
16129
|
tempDir
|
|
15747
16130
|
);
|
|
15748
|
-
if (theirs) {
|
|
16131
|
+
if (theirs != null) {
|
|
16132
|
+
useAccumulatorAsMergeBase = true;
|
|
16133
|
+
} else if (await this.isPatchAlreadyApplied(
|
|
16134
|
+
patch.patch_content,
|
|
16135
|
+
filePath,
|
|
16136
|
+
accumulatorEntry.content,
|
|
16137
|
+
tempGit,
|
|
16138
|
+
tempDir
|
|
16139
|
+
)) {
|
|
16140
|
+
theirs = accumulatorEntry.content;
|
|
15749
16141
|
useAccumulatorAsMergeBase = true;
|
|
15750
16142
|
}
|
|
15751
16143
|
}
|
|
@@ -15762,7 +16154,7 @@ var ReplayApplicator = class {
|
|
|
15762
16154
|
}
|
|
15763
16155
|
let effective_theirs = theirs;
|
|
15764
16156
|
let baseMismatchSkipped = false;
|
|
15765
|
-
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
16157
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
15766
16158
|
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
15767
16159
|
try {
|
|
15768
16160
|
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
@@ -15778,7 +16170,7 @@ var ReplayApplicator = class {
|
|
|
15778
16170
|
baseMismatchSkipped = true;
|
|
15779
16171
|
}
|
|
15780
16172
|
}
|
|
15781
|
-
if (base == null &&
|
|
16173
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
15782
16174
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
15783
16175
|
content: effective_theirs,
|
|
15784
16176
|
baseGeneration: patch.base_generation
|
|
@@ -15788,7 +16180,7 @@ var ReplayApplicator = class {
|
|
|
15788
16180
|
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
15789
16181
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
15790
16182
|
}
|
|
15791
|
-
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
16183
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
15792
16184
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
15793
16185
|
const outDir2 = (0, import_node_path3.dirname)(oursPath);
|
|
15794
16186
|
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
@@ -15802,16 +16194,20 @@ var ReplayApplicator = class {
|
|
|
15802
16194
|
conflictMetadata: metadata
|
|
15803
16195
|
};
|
|
15804
16196
|
}
|
|
16197
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
16198
|
+
content: merged2.content,
|
|
16199
|
+
baseGeneration: patch.base_generation
|
|
16200
|
+
});
|
|
15805
16201
|
return { file: resolvedPath, status: "merged" };
|
|
15806
16202
|
}
|
|
15807
|
-
if (
|
|
16203
|
+
if (effective_theirs == null) {
|
|
15808
16204
|
return {
|
|
15809
16205
|
file: resolvedPath,
|
|
15810
16206
|
status: "skipped",
|
|
15811
16207
|
reason: "missing-content"
|
|
15812
16208
|
};
|
|
15813
16209
|
}
|
|
15814
|
-
if (base == null && !useAccumulatorAsMergeBase ||
|
|
16210
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
15815
16211
|
return {
|
|
15816
16212
|
file: resolvedPath,
|
|
15817
16213
|
status: "skipped",
|
|
@@ -15830,7 +16226,7 @@ var ReplayApplicator = class {
|
|
|
15830
16226
|
const outDir = (0, import_node_path3.dirname)(oursPath);
|
|
15831
16227
|
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
15832
16228
|
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
15833
|
-
if (effective_theirs && !merged.hasConflicts) {
|
|
16229
|
+
if (effective_theirs != null && !merged.hasConflicts) {
|
|
15834
16230
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
15835
16231
|
content: effective_theirs,
|
|
15836
16232
|
baseGeneration: patch.base_generation
|
|
@@ -15928,6 +16324,28 @@ var ReplayApplicator = class {
|
|
|
15928
16324
|
return null;
|
|
15929
16325
|
}
|
|
15930
16326
|
}
|
|
16327
|
+
/**
|
|
16328
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
16329
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
16330
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
16331
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
16332
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
16333
|
+
*/
|
|
16334
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
16335
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
16336
|
+
if (!fileDiff) return false;
|
|
16337
|
+
try {
|
|
16338
|
+
const tempFilePath = (0, import_node_path3.join)(tempDir, filePath);
|
|
16339
|
+
await (0, import_promises.mkdir)((0, import_node_path3.dirname)(tempFilePath), { recursive: true });
|
|
16340
|
+
await (0, import_promises.writeFile)(tempFilePath, content);
|
|
16341
|
+
await tempGit.exec(["add", filePath]);
|
|
16342
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
16343
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
16344
|
+
return true;
|
|
16345
|
+
} catch {
|
|
16346
|
+
return false;
|
|
16347
|
+
}
|
|
16348
|
+
}
|
|
15931
16349
|
extractFileDiff(patchContent, filePath) {
|
|
15932
16350
|
const lines = patchContent.split("\n");
|
|
15933
16351
|
const diffLines = [];
|
|
@@ -16092,6 +16510,33 @@ var ReplayService = class {
|
|
|
16092
16510
|
committer;
|
|
16093
16511
|
lockManager;
|
|
16094
16512
|
outputDir;
|
|
16513
|
+
/** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
|
|
16514
|
+
_lastApplyResults = [];
|
|
16515
|
+
/**
|
|
16516
|
+
* Service-level warnings accumulated during a single runReplay() call.
|
|
16517
|
+
* Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
|
|
16518
|
+
* Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
|
|
16519
|
+
* (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
|
|
16520
|
+
* heuristic. Cleared at the top of `runReplay()`.
|
|
16521
|
+
*/
|
|
16522
|
+
warnings = [];
|
|
16523
|
+
/**
|
|
16524
|
+
* @internal Test-only accessor.
|
|
16525
|
+
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
16526
|
+
* to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
|
|
16527
|
+
*/
|
|
16528
|
+
get applicatorRef() {
|
|
16529
|
+
return this.applicator;
|
|
16530
|
+
}
|
|
16531
|
+
/**
|
|
16532
|
+
* @internal Test-only accessor.
|
|
16533
|
+
* Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
|
|
16534
|
+
* this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
|
|
16535
|
+
* (FER-9791). Returns an empty array if no patches have been applied yet.
|
|
16536
|
+
*/
|
|
16537
|
+
getLastResults() {
|
|
16538
|
+
return this._lastApplyResults;
|
|
16539
|
+
}
|
|
16095
16540
|
constructor(outputDir, _config) {
|
|
16096
16541
|
const git = new GitClient(outputDir);
|
|
16097
16542
|
this.git = git;
|
|
@@ -16101,20 +16546,66 @@ var ReplayService = class {
|
|
|
16101
16546
|
this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
|
|
16102
16547
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
16103
16548
|
}
|
|
16104
|
-
|
|
16549
|
+
/**
|
|
16550
|
+
* Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
|
|
16551
|
+
* detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
|
|
16552
|
+
* the generation commit (or unchanged for dry-run).
|
|
16553
|
+
*
|
|
16554
|
+
* Does NOT apply patches — the returned handle must be passed to
|
|
16555
|
+
* `applyPreparedReplay()` to complete the run. No disk writes to the lockfile
|
|
16556
|
+
* happen here; lockfile persistence is deferred to phase 2.
|
|
16557
|
+
*
|
|
16558
|
+
* Callers may land additional commits on HEAD between `prepareReplay` and
|
|
16559
|
+
* `applyPreparedReplay` (e.g., `[fern-autoversion]`).
|
|
16560
|
+
*/
|
|
16561
|
+
async prepareReplay(options) {
|
|
16562
|
+
this.warnings = [];
|
|
16563
|
+
this.detector.warnings.length = 0;
|
|
16105
16564
|
if (options?.skipApplication) {
|
|
16106
|
-
return this.
|
|
16565
|
+
return this._prepareSkipApplication(options);
|
|
16107
16566
|
}
|
|
16108
16567
|
const flow = this.determineFlow();
|
|
16109
16568
|
switch (flow) {
|
|
16110
16569
|
case "first-generation":
|
|
16111
|
-
return this.
|
|
16570
|
+
return this._prepareFirstGeneration(options);
|
|
16112
16571
|
case "no-patches":
|
|
16113
|
-
return this.
|
|
16572
|
+
return this._prepareNoPatchesRegeneration(options);
|
|
16114
16573
|
case "normal-regeneration":
|
|
16115
|
-
return this.
|
|
16574
|
+
return this._prepareNormalRegeneration(options);
|
|
16116
16575
|
}
|
|
16117
16576
|
}
|
|
16577
|
+
/**
|
|
16578
|
+
* Phase 2 of the two-phase replay flow. Applies patches, rebases them against
|
|
16579
|
+
* the generation, saves the lockfile, and commits `[fern-replay]` (or stages
|
|
16580
|
+
* under `stageOnly`). Operates on HEAD at call time — mid-phase commits are
|
|
16581
|
+
* respected.
|
|
16582
|
+
*
|
|
16583
|
+
* For terminal handles (dry-run, first-gen has no patch work, skip-app does
|
|
16584
|
+
* its own post-commit logic), this returns the precomputed report.
|
|
16585
|
+
*/
|
|
16586
|
+
async applyPreparedReplay(prep, options) {
|
|
16587
|
+
if (prep._prepared.terminal) {
|
|
16588
|
+
return prep._prepared.preparedReport;
|
|
16589
|
+
}
|
|
16590
|
+
switch (prep._prepared.flow) {
|
|
16591
|
+
case "first-generation":
|
|
16592
|
+
return this._applyFirstGeneration(prep);
|
|
16593
|
+
case "skip-application":
|
|
16594
|
+
return this._applySkipApplication(prep, options);
|
|
16595
|
+
case "no-patches":
|
|
16596
|
+
return this._applyNoPatchesRegeneration(prep, options);
|
|
16597
|
+
case "normal-regeneration":
|
|
16598
|
+
return this._applyNormalRegeneration(prep, options);
|
|
16599
|
+
}
|
|
16600
|
+
}
|
|
16601
|
+
/**
|
|
16602
|
+
* Backwards-compatible atomic entry point. Composes `prepareReplay` + `applyPreparedReplay`.
|
|
16603
|
+
* Behavior is byte-identical to the pre-split implementation for all existing callers.
|
|
16604
|
+
*/
|
|
16605
|
+
async runReplay(options) {
|
|
16606
|
+
const prep = await this.prepareReplay(options);
|
|
16607
|
+
return this.applyPreparedReplay(prep, options);
|
|
16608
|
+
}
|
|
16118
16609
|
/**
|
|
16119
16610
|
* Sync the lockfile after a divergent PR was squash-merged.
|
|
16120
16611
|
* Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
|
|
@@ -16175,6 +16666,7 @@ var ReplayService = class {
|
|
|
16175
16666
|
);
|
|
16176
16667
|
}
|
|
16177
16668
|
this.lockManager.save();
|
|
16669
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
16178
16670
|
}
|
|
16179
16671
|
determineFlow() {
|
|
16180
16672
|
try {
|
|
@@ -16187,15 +16679,35 @@ var ReplayService = class {
|
|
|
16187
16679
|
throw error;
|
|
16188
16680
|
}
|
|
16189
16681
|
}
|
|
16190
|
-
|
|
16682
|
+
firstGenerationReport() {
|
|
16683
|
+
return {
|
|
16684
|
+
flow: "first-generation",
|
|
16685
|
+
patchesDetected: 0,
|
|
16686
|
+
patchesApplied: 0,
|
|
16687
|
+
patchesWithConflicts: 0,
|
|
16688
|
+
patchesSkipped: 0,
|
|
16689
|
+
conflicts: []
|
|
16690
|
+
};
|
|
16691
|
+
}
|
|
16692
|
+
async _prepareFirstGeneration(options) {
|
|
16191
16693
|
if (options?.dryRun) {
|
|
16694
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
16192
16695
|
return {
|
|
16193
16696
|
flow: "first-generation",
|
|
16194
|
-
|
|
16195
|
-
|
|
16196
|
-
|
|
16197
|
-
|
|
16198
|
-
|
|
16697
|
+
previousGenerationSha: null,
|
|
16698
|
+
currentGenerationSha: headSha,
|
|
16699
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
16700
|
+
_prepared: {
|
|
16701
|
+
flow: "first-generation",
|
|
16702
|
+
terminal: true,
|
|
16703
|
+
preparedReport: this.firstGenerationReport(),
|
|
16704
|
+
patchesToApply: [],
|
|
16705
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
16706
|
+
detectorWarnings: [],
|
|
16707
|
+
revertedCount: 0,
|
|
16708
|
+
genSha: headSha,
|
|
16709
|
+
optionsSnapshot: options
|
|
16710
|
+
}
|
|
16199
16711
|
};
|
|
16200
16712
|
}
|
|
16201
16713
|
const commitOpts = options ? {
|
|
@@ -16205,22 +16717,36 @@ var ReplayService = class {
|
|
|
16205
16717
|
} : void 0;
|
|
16206
16718
|
await this.committer.commitGeneration("Initial SDK generation", commitOpts);
|
|
16207
16719
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
16208
|
-
this.lockManager.
|
|
16720
|
+
this.lockManager.initializeInMemory(genRecord);
|
|
16209
16721
|
return {
|
|
16210
16722
|
flow: "first-generation",
|
|
16211
|
-
|
|
16212
|
-
|
|
16213
|
-
|
|
16214
|
-
|
|
16215
|
-
|
|
16723
|
+
previousGenerationSha: null,
|
|
16724
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
16725
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
16726
|
+
_prepared: {
|
|
16727
|
+
flow: "first-generation",
|
|
16728
|
+
terminal: false,
|
|
16729
|
+
patchesToApply: [],
|
|
16730
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
16731
|
+
detectorWarnings: [],
|
|
16732
|
+
revertedCount: 0,
|
|
16733
|
+
genSha: genRecord.commit_sha,
|
|
16734
|
+
optionsSnapshot: options
|
|
16735
|
+
}
|
|
16216
16736
|
};
|
|
16217
16737
|
}
|
|
16738
|
+
async _applyFirstGeneration(_prep) {
|
|
16739
|
+
this.lockManager.save();
|
|
16740
|
+
return this.firstGenerationReport();
|
|
16741
|
+
}
|
|
16218
16742
|
/**
|
|
16219
|
-
* Skip-application mode: commit the generation and update the
|
|
16220
|
-
* but don't detect or apply patches. Sets a marker
|
|
16221
|
-
* run skips revert detection in preGenerationRebase().
|
|
16743
|
+
* Skip-application mode phase 1: commit the generation and update the
|
|
16744
|
+
* in-memory lockfile, but don't detect or apply patches. Sets a marker
|
|
16745
|
+
* so the next normal run skips revert detection in preGenerationRebase().
|
|
16746
|
+
*
|
|
16747
|
+
* Disk save is deferred to `_applySkipApplication`.
|
|
16222
16748
|
*/
|
|
16223
|
-
async
|
|
16749
|
+
async _prepareSkipApplication(options) {
|
|
16224
16750
|
const commitOpts = options ? {
|
|
16225
16751
|
cliVersion: options.cliVersion ?? "unknown",
|
|
16226
16752
|
generatorVersions: options.generatorVersions ?? {},
|
|
@@ -16229,8 +16755,10 @@ var ReplayService = class {
|
|
|
16229
16755
|
await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
16230
16756
|
await this.cleanupStaleConflictMarkers();
|
|
16231
16757
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
16758
|
+
let previousGenerationSha = null;
|
|
16232
16759
|
try {
|
|
16233
|
-
this.lockManager.read();
|
|
16760
|
+
const lock = this.lockManager.read();
|
|
16761
|
+
previousGenerationSha = lock.current_generation ?? null;
|
|
16234
16762
|
this.lockManager.addGeneration(genRecord);
|
|
16235
16763
|
} catch (error) {
|
|
16236
16764
|
if (error instanceof LockfileNotFoundError) {
|
|
@@ -16240,7 +16768,26 @@ var ReplayService = class {
|
|
|
16240
16768
|
}
|
|
16241
16769
|
}
|
|
16242
16770
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
16771
|
+
return {
|
|
16772
|
+
flow: "skip-application",
|
|
16773
|
+
previousGenerationSha,
|
|
16774
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
16775
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
16776
|
+
_prepared: {
|
|
16777
|
+
flow: "skip-application",
|
|
16778
|
+
terminal: false,
|
|
16779
|
+
patchesToApply: [],
|
|
16780
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
16781
|
+
detectorWarnings: [],
|
|
16782
|
+
revertedCount: 0,
|
|
16783
|
+
genSha: genRecord.commit_sha,
|
|
16784
|
+
optionsSnapshot: options
|
|
16785
|
+
}
|
|
16786
|
+
};
|
|
16787
|
+
}
|
|
16788
|
+
async _applySkipApplication(_prep, options) {
|
|
16243
16789
|
this.lockManager.save();
|
|
16790
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
16244
16791
|
if (!options?.stageOnly) {
|
|
16245
16792
|
await this.committer.commitReplay(0);
|
|
16246
16793
|
} else {
|
|
@@ -16252,14 +16799,18 @@ var ReplayService = class {
|
|
|
16252
16799
|
patchesApplied: 0,
|
|
16253
16800
|
patchesWithConflicts: 0,
|
|
16254
16801
|
patchesSkipped: 0,
|
|
16255
|
-
conflicts: []
|
|
16802
|
+
conflicts: [],
|
|
16803
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
16256
16804
|
};
|
|
16257
16805
|
}
|
|
16258
|
-
async
|
|
16259
|
-
const
|
|
16260
|
-
const
|
|
16806
|
+
async _prepareNoPatchesRegeneration(options) {
|
|
16807
|
+
const preLock = this.lockManager.read();
|
|
16808
|
+
const previousGenerationSha = preLock.current_generation ?? null;
|
|
16809
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
16810
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
16261
16811
|
if (options?.dryRun) {
|
|
16262
|
-
|
|
16812
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
16813
|
+
const preparedReport = {
|
|
16263
16814
|
flow: "no-patches",
|
|
16264
16815
|
patchesDetected: newPatches.length,
|
|
16265
16816
|
patchesApplied: 0,
|
|
@@ -16268,9 +16819,43 @@ var ReplayService = class {
|
|
|
16268
16819
|
patchesReverted: revertedPatchIds.length,
|
|
16269
16820
|
conflicts: [],
|
|
16270
16821
|
wouldApply: newPatches,
|
|
16271
|
-
warnings:
|
|
16822
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
16823
|
+
};
|
|
16824
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
16825
|
+
return {
|
|
16826
|
+
flow: "no-patches",
|
|
16827
|
+
previousGenerationSha,
|
|
16828
|
+
currentGenerationSha: headSha,
|
|
16829
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
16830
|
+
_prepared: {
|
|
16831
|
+
flow: "no-patches",
|
|
16832
|
+
terminal: true,
|
|
16833
|
+
preparedReport,
|
|
16834
|
+
patchesToApply: [],
|
|
16835
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
16836
|
+
detectorWarnings,
|
|
16837
|
+
revertedCount: revertedPatchIds.length,
|
|
16838
|
+
genSha: headSha,
|
|
16839
|
+
optionsSnapshot: options
|
|
16840
|
+
}
|
|
16272
16841
|
};
|
|
16273
16842
|
}
|
|
16843
|
+
{
|
|
16844
|
+
const preCurrentGen = preLock.current_generation;
|
|
16845
|
+
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
16846
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
16847
|
+
for (const file of uniqueFiles) {
|
|
16848
|
+
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
16849
|
+
if (diff !== null && !diff.trim()) {
|
|
16850
|
+
absorbedFiles.add(file);
|
|
16851
|
+
}
|
|
16852
|
+
}
|
|
16853
|
+
if (absorbedFiles.size > 0) {
|
|
16854
|
+
newPatches = newPatches.filter(
|
|
16855
|
+
(p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
|
|
16856
|
+
);
|
|
16857
|
+
}
|
|
16858
|
+
}
|
|
16274
16859
|
for (const id of revertedPatchIds) {
|
|
16275
16860
|
try {
|
|
16276
16861
|
this.lockManager.removePatch(id);
|
|
@@ -16286,9 +16871,31 @@ var ReplayService = class {
|
|
|
16286
16871
|
await this.cleanupStaleConflictMarkers();
|
|
16287
16872
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
16288
16873
|
this.lockManager.addGeneration(genRecord);
|
|
16874
|
+
return {
|
|
16875
|
+
flow: "no-patches",
|
|
16876
|
+
previousGenerationSha,
|
|
16877
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
16878
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
16879
|
+
_prepared: {
|
|
16880
|
+
flow: "no-patches",
|
|
16881
|
+
terminal: false,
|
|
16882
|
+
patchesToApply: newPatches,
|
|
16883
|
+
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
16884
|
+
detectorWarnings,
|
|
16885
|
+
revertedCount: revertedPatchIds.length,
|
|
16886
|
+
genSha: genRecord.commit_sha,
|
|
16887
|
+
optionsSnapshot: options
|
|
16888
|
+
}
|
|
16889
|
+
};
|
|
16890
|
+
}
|
|
16891
|
+
async _applyNoPatchesRegeneration(prep, options) {
|
|
16892
|
+
const newPatches = prep._prepared.patchesToApply;
|
|
16893
|
+
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
16894
|
+
const genSha = prep._prepared.genSha;
|
|
16289
16895
|
let results = [];
|
|
16290
16896
|
if (newPatches.length > 0) {
|
|
16291
16897
|
results = await this.applicator.applyPatches(newPatches);
|
|
16898
|
+
this._lastApplyResults = results;
|
|
16292
16899
|
this.revertConflictingFiles(results);
|
|
16293
16900
|
for (const result of results) {
|
|
16294
16901
|
if (result.status === "conflict") {
|
|
@@ -16296,13 +16903,14 @@ var ReplayService = class {
|
|
|
16296
16903
|
}
|
|
16297
16904
|
}
|
|
16298
16905
|
}
|
|
16299
|
-
const rebaseCounts = await this.rebasePatches(results,
|
|
16906
|
+
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
16300
16907
|
for (const patch of newPatches) {
|
|
16301
16908
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
16302
16909
|
this.lockManager.addPatch(patch);
|
|
16303
16910
|
}
|
|
16304
16911
|
}
|
|
16305
16912
|
this.lockManager.save();
|
|
16913
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
16306
16914
|
if (newPatches.length > 0) {
|
|
16307
16915
|
if (!options?.stageOnly) {
|
|
16308
16916
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
@@ -16311,15 +16919,27 @@ var ReplayService = class {
|
|
|
16311
16919
|
await this.committer.stageAll();
|
|
16312
16920
|
}
|
|
16313
16921
|
}
|
|
16314
|
-
|
|
16922
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
16923
|
+
return this.buildReport(
|
|
16924
|
+
"no-patches",
|
|
16925
|
+
newPatches,
|
|
16926
|
+
results,
|
|
16927
|
+
prep._prepared.optionsSnapshot,
|
|
16928
|
+
warnings,
|
|
16929
|
+
rebaseCounts,
|
|
16930
|
+
void 0,
|
|
16931
|
+
prep._prepared.revertedCount
|
|
16932
|
+
);
|
|
16315
16933
|
}
|
|
16316
|
-
async
|
|
16934
|
+
async _prepareNormalRegeneration(options) {
|
|
16935
|
+
const preLock = this.lockManager.read();
|
|
16936
|
+
const previousGenerationSha = preLock.current_generation ?? null;
|
|
16317
16937
|
if (options?.dryRun) {
|
|
16318
16938
|
const existingPatches2 = this.lockManager.getPatches();
|
|
16319
16939
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
16320
|
-
const
|
|
16940
|
+
const warnings = [...this.detector.warnings, ...this.warnings];
|
|
16321
16941
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
16322
|
-
|
|
16942
|
+
const preparedReport = {
|
|
16323
16943
|
flow: "normal-regeneration",
|
|
16324
16944
|
patchesDetected: allPatches2.length,
|
|
16325
16945
|
patchesApplied: 0,
|
|
@@ -16328,7 +16948,25 @@ var ReplayService = class {
|
|
|
16328
16948
|
patchesReverted: dryRunReverted.length,
|
|
16329
16949
|
conflicts: [],
|
|
16330
16950
|
wouldApply: allPatches2,
|
|
16331
|
-
warnings:
|
|
16951
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
16952
|
+
};
|
|
16953
|
+
const headSha = await this.git.exec(["rev-parse", "HEAD"]).then((s) => s.trim()).catch(() => "");
|
|
16954
|
+
return {
|
|
16955
|
+
flow: "normal-regeneration",
|
|
16956
|
+
previousGenerationSha,
|
|
16957
|
+
currentGenerationSha: headSha,
|
|
16958
|
+
baseBranchHead: options.baseBranchHead ?? null,
|
|
16959
|
+
_prepared: {
|
|
16960
|
+
flow: "normal-regeneration",
|
|
16961
|
+
terminal: true,
|
|
16962
|
+
preparedReport,
|
|
16963
|
+
patchesToApply: [],
|
|
16964
|
+
newPatchIds: /* @__PURE__ */ new Set(),
|
|
16965
|
+
detectorWarnings: [...this.detector.warnings],
|
|
16966
|
+
revertedCount: dryRunReverted.length,
|
|
16967
|
+
genSha: headSha,
|
|
16968
|
+
optionsSnapshot: options
|
|
16969
|
+
}
|
|
16332
16970
|
};
|
|
16333
16971
|
}
|
|
16334
16972
|
let existingPatches = this.lockManager.getPatches();
|
|
@@ -16349,7 +16987,7 @@ var ReplayService = class {
|
|
|
16349
16987
|
}
|
|
16350
16988
|
existingPatches = this.lockManager.getPatches();
|
|
16351
16989
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
16352
|
-
const
|
|
16990
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
16353
16991
|
if (removedByPreRebase.length > 0) {
|
|
16354
16992
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
16355
16993
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -16380,14 +17018,39 @@ var ReplayService = class {
|
|
|
16380
17018
|
await this.cleanupStaleConflictMarkers();
|
|
16381
17019
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
16382
17020
|
this.lockManager.addGeneration(genRecord);
|
|
17021
|
+
return {
|
|
17022
|
+
flow: "normal-regeneration",
|
|
17023
|
+
previousGenerationSha,
|
|
17024
|
+
currentGenerationSha: genRecord.commit_sha,
|
|
17025
|
+
baseBranchHead: options?.baseBranchHead ?? null,
|
|
17026
|
+
_prepared: {
|
|
17027
|
+
flow: "normal-regeneration",
|
|
17028
|
+
terminal: false,
|
|
17029
|
+
patchesToApply: allPatches,
|
|
17030
|
+
newPatchIds: new Set(newPatches.map((p) => p.id)),
|
|
17031
|
+
preRebaseCounts,
|
|
17032
|
+
detectorWarnings,
|
|
17033
|
+
revertedCount: revertedPatchIds.length,
|
|
17034
|
+
genSha: genRecord.commit_sha,
|
|
17035
|
+
optionsSnapshot: options
|
|
17036
|
+
}
|
|
17037
|
+
};
|
|
17038
|
+
}
|
|
17039
|
+
async _applyNormalRegeneration(prep, options) {
|
|
17040
|
+
const allPatches = prep._prepared.patchesToApply;
|
|
17041
|
+
const newPatchIds = prep._prepared.newPatchIds;
|
|
17042
|
+
const detectorWarnings = prep._prepared.detectorWarnings;
|
|
17043
|
+
const genSha = prep._prepared.genSha;
|
|
16383
17044
|
const results = await this.applicator.applyPatches(allPatches);
|
|
17045
|
+
this._lastApplyResults = results;
|
|
16384
17046
|
this.revertConflictingFiles(results);
|
|
16385
17047
|
for (const result of results) {
|
|
16386
17048
|
if (result.status === "conflict") {
|
|
16387
17049
|
result.patch.status = "unresolved";
|
|
16388
17050
|
}
|
|
16389
17051
|
}
|
|
16390
|
-
const rebaseCounts = await this.rebasePatches(results,
|
|
17052
|
+
const rebaseCounts = await this.rebasePatches(results, genSha);
|
|
17053
|
+
const newPatches = allPatches.filter((p) => newPatchIds.has(p.id));
|
|
16391
17054
|
for (const patch of newPatches) {
|
|
16392
17055
|
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
16393
17056
|
this.lockManager.addPatch(patch);
|
|
@@ -16402,21 +17065,23 @@ var ReplayService = class {
|
|
|
16402
17065
|
}
|
|
16403
17066
|
}
|
|
16404
17067
|
this.lockManager.save();
|
|
17068
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
16405
17069
|
if (options?.stageOnly) {
|
|
16406
17070
|
await this.committer.stageAll();
|
|
16407
17071
|
} else {
|
|
16408
17072
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
16409
17073
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
16410
17074
|
}
|
|
17075
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
16411
17076
|
return this.buildReport(
|
|
16412
17077
|
"normal-regeneration",
|
|
16413
17078
|
allPatches,
|
|
16414
17079
|
results,
|
|
16415
|
-
|
|
17080
|
+
prep._prepared.optionsSnapshot,
|
|
16416
17081
|
warnings,
|
|
16417
17082
|
rebaseCounts,
|
|
16418
|
-
preRebaseCounts,
|
|
16419
|
-
|
|
17083
|
+
prep._prepared.preRebaseCounts,
|
|
17084
|
+
prep._prepared.revertedCount
|
|
16420
17085
|
);
|
|
16421
17086
|
}
|
|
16422
17087
|
/**
|
|
@@ -16431,6 +17096,7 @@ var ReplayService = class {
|
|
|
16431
17096
|
let keptAsUserOwned = 0;
|
|
16432
17097
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
16433
17098
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
17099
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
16434
17100
|
for (const result of results) {
|
|
16435
17101
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
16436
17102
|
const patch = result.patch;
|
|
@@ -16451,34 +17117,78 @@ var ReplayService = class {
|
|
|
16451
17117
|
const patch = result.patch;
|
|
16452
17118
|
if (patch.base_generation === currentGenSha) continue;
|
|
16453
17119
|
try {
|
|
16454
|
-
const
|
|
16455
|
-
|
|
17120
|
+
const originalByResolved = {};
|
|
17121
|
+
if (result.resolvedFiles) {
|
|
17122
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
17123
|
+
originalByResolved[resolved] = orig;
|
|
17124
|
+
}
|
|
17125
|
+
}
|
|
17126
|
+
const isUserOwnedPerFile = await Promise.all(
|
|
16456
17127
|
patch.files.map(async (file) => {
|
|
16457
|
-
if (file === ".fernignore")
|
|
17128
|
+
if (file === ".fernignore") return true;
|
|
17129
|
+
if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
|
|
16458
17130
|
return true;
|
|
16459
17131
|
}
|
|
16460
|
-
if (
|
|
17132
|
+
if (patch.user_owned) return true;
|
|
17133
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
16461
17134
|
return true;
|
|
16462
17135
|
}
|
|
16463
17136
|
const content = await this.git.showFile(currentGenSha, file);
|
|
16464
17137
|
return content === null;
|
|
16465
17138
|
})
|
|
16466
17139
|
);
|
|
16467
|
-
const
|
|
16468
|
-
|
|
16469
|
-
|
|
16470
|
-
|
|
16471
|
-
|
|
17140
|
+
const hasProtectedFiles = patch.files.some(
|
|
17141
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
|
|
17142
|
+
);
|
|
17143
|
+
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
17144
|
+
if (allUserOwned && patch.files.length > 0) {
|
|
17145
|
+
const baseChanged = patch.base_generation !== currentGenSha;
|
|
17146
|
+
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
17147
|
+
if (userDiff != null && userDiff.trim()) {
|
|
17148
|
+
const newHash = this.detector.computeContentHash(userDiff);
|
|
17149
|
+
patch.patch_content = userDiff;
|
|
17150
|
+
patch.content_hash = newHash;
|
|
17151
|
+
}
|
|
17152
|
+
patch.base_generation = currentGenSha;
|
|
17153
|
+
try {
|
|
17154
|
+
this.lockManager.updatePatch(patch.id, {
|
|
17155
|
+
base_generation: currentGenSha,
|
|
17156
|
+
...!patch.user_owned ? { user_owned: true } : {},
|
|
17157
|
+
...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
|
|
17158
|
+
});
|
|
17159
|
+
} catch {
|
|
17160
|
+
}
|
|
17161
|
+
patch.user_owned = true;
|
|
16472
17162
|
keptAsUserOwned++;
|
|
17163
|
+
if (baseChanged) repointed++;
|
|
16473
17164
|
continue;
|
|
16474
17165
|
}
|
|
16475
17166
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
16476
|
-
if (
|
|
17167
|
+
if (diff === null) continue;
|
|
17168
|
+
if (!diff.trim()) {
|
|
17169
|
+
if (hasProtectedFiles) {
|
|
17170
|
+
patch.base_generation = currentGenSha;
|
|
17171
|
+
try {
|
|
17172
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
17173
|
+
} catch {
|
|
17174
|
+
}
|
|
17175
|
+
keptAsUserOwned++;
|
|
17176
|
+
continue;
|
|
17177
|
+
}
|
|
16477
17178
|
this.lockManager.removePatch(patch.id);
|
|
16478
17179
|
absorbedPatchIds.add(patch.id);
|
|
16479
17180
|
absorbed++;
|
|
16480
17181
|
continue;
|
|
16481
17182
|
}
|
|
17183
|
+
if (hasProtectedFiles) {
|
|
17184
|
+
patch.base_generation = currentGenSha;
|
|
17185
|
+
try {
|
|
17186
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
17187
|
+
} catch {
|
|
17188
|
+
}
|
|
17189
|
+
keptAsUserOwned++;
|
|
17190
|
+
continue;
|
|
17191
|
+
}
|
|
16482
17192
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
16483
17193
|
if (seenContentHashes.has(newContentHash)) {
|
|
16484
17194
|
this.lockManager.removePatch(patch.id);
|
|
@@ -16487,17 +17197,89 @@ var ReplayService = class {
|
|
|
16487
17197
|
continue;
|
|
16488
17198
|
}
|
|
16489
17199
|
seenContentHashes.add(newContentHash);
|
|
16490
|
-
|
|
16491
|
-
|
|
16492
|
-
|
|
16493
|
-
|
|
16494
|
-
|
|
17200
|
+
patch.base_generation = currentGenSha;
|
|
17201
|
+
patch.patch_content = diff;
|
|
17202
|
+
patch.content_hash = newContentHash;
|
|
17203
|
+
try {
|
|
17204
|
+
this.lockManager.updatePatch(patch.id, {
|
|
17205
|
+
base_generation: currentGenSha,
|
|
17206
|
+
patch_content: diff,
|
|
17207
|
+
content_hash: newContentHash
|
|
17208
|
+
});
|
|
17209
|
+
} catch {
|
|
17210
|
+
}
|
|
16495
17211
|
contentRebased++;
|
|
16496
17212
|
} catch {
|
|
16497
17213
|
}
|
|
16498
17214
|
}
|
|
16499
17215
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
16500
17216
|
}
|
|
17217
|
+
/**
|
|
17218
|
+
* Determine whether `file` is user-owned (never produced by the generator).
|
|
17219
|
+
*
|
|
17220
|
+
* Resolution order (first match wins):
|
|
17221
|
+
* 1. `patch.user_owned === true` — authoritative, persisted across cycles.
|
|
17222
|
+
* 2. `.fernignore` itself, or file matches a .fernignore pattern.
|
|
17223
|
+
* 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
|
|
17224
|
+
* creation. Works for patches that predate the `user_owned` field or
|
|
17225
|
+
* whose flag was lost. Immune to rename tricks — relies on raw patch text.
|
|
17226
|
+
* 4. Tree-based check against `patch.base_generation` when it is reachable
|
|
17227
|
+
* AND distinct from `currentGenSha`. If the base tree is unreachable
|
|
17228
|
+
* (shallow clone / aggressive `git gc --prune`), emit a one-time
|
|
17229
|
+
* customer-actionable warning (deduplicated via warnedGens) and
|
|
17230
|
+
* conservatively treat as user-owned — strictly safer than silent
|
|
17231
|
+
* absorption (FER-9809).
|
|
17232
|
+
* 5. Final fallback — check `currentGenSha` when there is no usable base
|
|
17233
|
+
* (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
|
|
17234
|
+
*
|
|
17235
|
+
* `originalFileName` is the pre-rename path when the generator moved the
|
|
17236
|
+
* file between the patch's base and the current generation. Tree lookups
|
|
17237
|
+
* at ancestor generations use the original path — that's where the
|
|
17238
|
+
* generator produced the content.
|
|
17239
|
+
*/
|
|
17240
|
+
async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
|
|
17241
|
+
if (patch.user_owned) return true;
|
|
17242
|
+
if (file === ".fernignore") return true;
|
|
17243
|
+
if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) return true;
|
|
17244
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
17245
|
+
return true;
|
|
17246
|
+
}
|
|
17247
|
+
const base = patch.base_generation;
|
|
17248
|
+
if (base && base !== currentGenSha) {
|
|
17249
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
17250
|
+
if (!reachable) {
|
|
17251
|
+
if (!warnedGens.has(base)) {
|
|
17252
|
+
warnedGens.add(base);
|
|
17253
|
+
this.warnings.push(
|
|
17254
|
+
`Base generation ${base.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
|
|
17255
|
+
);
|
|
17256
|
+
}
|
|
17257
|
+
return true;
|
|
17258
|
+
}
|
|
17259
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
17260
|
+
}
|
|
17261
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
17262
|
+
}
|
|
17263
|
+
/**
|
|
17264
|
+
* Returns true if `file`'s diff section in `patchContent` starts with
|
|
17265
|
+
* `--- /dev/null`, meaning this file was introduced by the patch (user
|
|
17266
|
+
* creation). Used as a legacy-safe fallback when `patch.user_owned` is
|
|
17267
|
+
* absent or cleared.
|
|
17268
|
+
*/
|
|
17269
|
+
fileIsCreationInPatchContent(patchContent, file) {
|
|
17270
|
+
const lines = patchContent.split("\n");
|
|
17271
|
+
let inFileSection = false;
|
|
17272
|
+
for (const line of lines) {
|
|
17273
|
+
if (line.startsWith("diff --git ")) {
|
|
17274
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
17275
|
+
continue;
|
|
17276
|
+
}
|
|
17277
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
17278
|
+
return line === "--- /dev/null";
|
|
17279
|
+
}
|
|
17280
|
+
}
|
|
17281
|
+
return false;
|
|
17282
|
+
}
|
|
16501
17283
|
/**
|
|
16502
17284
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
16503
17285
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -16511,13 +17293,14 @@ var ReplayService = class {
|
|
|
16511
17293
|
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
16512
17294
|
if (cleanFiles.length === 0) return;
|
|
16513
17295
|
const patch = result.patch;
|
|
17296
|
+
if (patch.user_owned) return;
|
|
16514
17297
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
17298
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
16515
17299
|
const generatorCleanFiles = [];
|
|
16516
17300
|
for (const file of cleanFiles) {
|
|
16517
|
-
if (file
|
|
16518
|
-
|
|
16519
|
-
|
|
16520
|
-
if (content === null) continue;
|
|
17301
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
17302
|
+
continue;
|
|
17303
|
+
}
|
|
16521
17304
|
generatorCleanFiles.push(file);
|
|
16522
17305
|
}
|
|
16523
17306
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -16554,7 +17337,35 @@ var ReplayService = class {
|
|
|
16554
17337
|
let conflictResolved = 0;
|
|
16555
17338
|
let conflictAbsorbed = 0;
|
|
16556
17339
|
let contentRefreshed = 0;
|
|
17340
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
17341
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
17342
|
+
const reachabilityCache = /* @__PURE__ */ new Map();
|
|
17343
|
+
const isReachable = async (sha) => {
|
|
17344
|
+
const cached = reachabilityCache.get(sha);
|
|
17345
|
+
if (cached !== void 0) return cached;
|
|
17346
|
+
const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
|
|
17347
|
+
reachabilityCache.set(sha, reachable);
|
|
17348
|
+
return reachable;
|
|
17349
|
+
};
|
|
17350
|
+
const allPatchFilesUserOwned = async (patch) => {
|
|
17351
|
+
if (patch.user_owned) return true;
|
|
17352
|
+
if (patch.files.length === 0) return false;
|
|
17353
|
+
for (const file of patch.files) {
|
|
17354
|
+
if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
|
|
17355
|
+
return false;
|
|
17356
|
+
}
|
|
17357
|
+
}
|
|
17358
|
+
return true;
|
|
17359
|
+
};
|
|
16557
17360
|
for (const patch of patches) {
|
|
17361
|
+
if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
|
|
17362
|
+
if (!await isReachable(patch.base_generation)) {
|
|
17363
|
+
warnedGens.add(patch.base_generation);
|
|
17364
|
+
this.warnings.push(
|
|
17365
|
+
`Base generation ${patch.base_generation.slice(0, 7)} is unreachable (shallow clone or pruned history). Treating affected files as user-owned to avoid silent data loss. Run \`git fetch --unshallow\` or avoid \`git gc --prune\` to restore full history.`
|
|
17366
|
+
);
|
|
17367
|
+
}
|
|
17368
|
+
}
|
|
16558
17369
|
if (patch.status != null) {
|
|
16559
17370
|
delete patch.status;
|
|
16560
17371
|
continue;
|
|
@@ -16564,6 +17375,7 @@ var ReplayService = class {
|
|
|
16564
17375
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
16565
17376
|
if (diff === null) continue;
|
|
16566
17377
|
if (!diff.trim()) {
|
|
17378
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
16567
17379
|
this.lockManager.removePatch(patch.id);
|
|
16568
17380
|
contentRefreshed++;
|
|
16569
17381
|
continue;
|
|
@@ -16575,6 +17387,12 @@ var ReplayService = class {
|
|
|
16575
17387
|
if (hasStaleMarkers) {
|
|
16576
17388
|
continue;
|
|
16577
17389
|
}
|
|
17390
|
+
const isProtected = patch.files.some(
|
|
17391
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
|
|
17392
|
+
);
|
|
17393
|
+
if (isProtected) {
|
|
17394
|
+
continue;
|
|
17395
|
+
}
|
|
16578
17396
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
16579
17397
|
if (newContentHash !== patch.content_hash) {
|
|
16580
17398
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -16600,6 +17418,13 @@ var ReplayService = class {
|
|
|
16600
17418
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
16601
17419
|
if (diff === null) continue;
|
|
16602
17420
|
if (!diff.trim()) {
|
|
17421
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
17422
|
+
try {
|
|
17423
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
17424
|
+
} catch {
|
|
17425
|
+
}
|
|
17426
|
+
continue;
|
|
17427
|
+
}
|
|
16603
17428
|
this.lockManager.removePatch(patch.id);
|
|
16604
17429
|
conflictAbsorbed++;
|
|
16605
17430
|
continue;
|
|
@@ -17110,7 +17935,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
17110
17935
|
(0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
|
|
17111
17936
|
}
|
|
17112
17937
|
function computeContentHash(patchContent) {
|
|
17113
|
-
const
|
|
17938
|
+
const lines = patchContent.split("\n");
|
|
17939
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
17940
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
17941
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
17114
17942
|
return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
17115
17943
|
}
|
|
17116
17944
|
|