@fern-api/replay 0.10.3 → 0.11.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 +753 -67
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +645 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -4
- package/dist/index.d.ts +177 -4
- package/dist/index.js +639 -66
- 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 });
|
|
@@ -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;
|
|
@@ -13340,6 +13560,7 @@ var ReplayDetector = class {
|
|
|
13340
13560
|
}
|
|
13341
13561
|
const log = await this.git.exec([
|
|
13342
13562
|
"log",
|
|
13563
|
+
"--first-parent",
|
|
13343
13564
|
"--format=%H%x00%an%x00%ae%x00%s",
|
|
13344
13565
|
`${lastGen.commit_sha}..HEAD`,
|
|
13345
13566
|
"--",
|
|
@@ -13355,11 +13576,20 @@ var ReplayDetector = class {
|
|
|
13355
13576
|
if (isGenerationCommit(commit)) {
|
|
13356
13577
|
continue;
|
|
13357
13578
|
}
|
|
13358
|
-
|
|
13359
|
-
if (parents.length > 1) {
|
|
13579
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
13360
13580
|
continue;
|
|
13361
13581
|
}
|
|
13362
|
-
|
|
13582
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
13583
|
+
if (parents.length > 1) {
|
|
13584
|
+
const compositePatch = await this.createMergeCompositePatch({
|
|
13585
|
+
mergeCommit: commit,
|
|
13586
|
+
firstParent: parents[0],
|
|
13587
|
+
lastGen,
|
|
13588
|
+
lock
|
|
13589
|
+
});
|
|
13590
|
+
if (compositePatch) {
|
|
13591
|
+
newPatches.push(compositePatch);
|
|
13592
|
+
}
|
|
13363
13593
|
continue;
|
|
13364
13594
|
}
|
|
13365
13595
|
let patchContent;
|
|
@@ -13371,12 +13601,31 @@ var ReplayDetector = class {
|
|
|
13371
13601
|
);
|
|
13372
13602
|
continue;
|
|
13373
13603
|
}
|
|
13374
|
-
|
|
13604
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
13375
13605
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
13376
13606
|
continue;
|
|
13377
13607
|
}
|
|
13378
13608
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
13379
|
-
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
|
+
}
|
|
13380
13629
|
if (files.length === 0) {
|
|
13381
13630
|
continue;
|
|
13382
13631
|
}
|
|
@@ -13388,14 +13637,16 @@ var ReplayDetector = class {
|
|
|
13388
13637
|
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
13389
13638
|
base_generation: lastGen.commit_sha,
|
|
13390
13639
|
files,
|
|
13391
|
-
patch_content: patchContent
|
|
13640
|
+
patch_content: patchContent,
|
|
13641
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
13392
13642
|
});
|
|
13393
13643
|
}
|
|
13394
|
-
|
|
13644
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
13645
|
+
survivingPatches.reverse();
|
|
13395
13646
|
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
13396
13647
|
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
13397
|
-
for (let i = 0; i <
|
|
13398
|
-
const patch =
|
|
13648
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
13649
|
+
const patch = survivingPatches[i];
|
|
13399
13650
|
if (!isRevertCommit(patch.original_message)) continue;
|
|
13400
13651
|
let body = "";
|
|
13401
13652
|
try {
|
|
@@ -13424,7 +13675,7 @@ var ReplayDetector = class {
|
|
|
13424
13675
|
if (matchedExisting) continue;
|
|
13425
13676
|
let matchedNew = false;
|
|
13426
13677
|
if (revertedSha) {
|
|
13427
|
-
const idx =
|
|
13678
|
+
const idx = survivingPatches.findIndex(
|
|
13428
13679
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_commit === revertedSha
|
|
13429
13680
|
);
|
|
13430
13681
|
if (idx !== -1) {
|
|
@@ -13434,7 +13685,7 @@ var ReplayDetector = class {
|
|
|
13434
13685
|
}
|
|
13435
13686
|
}
|
|
13436
13687
|
if (!matchedNew && revertedMessage) {
|
|
13437
|
-
const idx =
|
|
13688
|
+
const idx = survivingPatches.findIndex(
|
|
13438
13689
|
(p, j) => j !== i && !revertIndicesToRemove.has(j) && p.original_message === revertedMessage
|
|
13439
13690
|
);
|
|
13440
13691
|
if (idx !== -1) {
|
|
@@ -13446,18 +13697,155 @@ var ReplayDetector = class {
|
|
|
13446
13697
|
revertIndicesToRemove.add(i);
|
|
13447
13698
|
}
|
|
13448
13699
|
}
|
|
13449
|
-
const filteredPatches =
|
|
13700
|
+
const filteredPatches = survivingPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
13450
13701
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
13451
13702
|
}
|
|
13452
13703
|
/**
|
|
13453
13704
|
* Compute content hash for deduplication.
|
|
13454
|
-
*
|
|
13455
|
-
*
|
|
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).
|
|
13456
13719
|
*/
|
|
13457
13720
|
computeContentHash(patchContent) {
|
|
13458
|
-
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*$/, "");
|
|
13459
13725
|
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
13460
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
|
+
}
|
|
13801
|
+
/**
|
|
13802
|
+
* Create a single composite patch for a merge commit by diffing the merge
|
|
13803
|
+
* commit against its first parent. This captures the net change of the
|
|
13804
|
+
* entire merged branch as one patch, eliminating accumulator chaining and
|
|
13805
|
+
* zero-net-change ghost conflicts.
|
|
13806
|
+
*/
|
|
13807
|
+
async createMergeCompositePatch(input) {
|
|
13808
|
+
const { mergeCommit, firstParent, lastGen, lock } = input;
|
|
13809
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
13810
|
+
const filesOutput = await this.git.exec([
|
|
13811
|
+
"diff",
|
|
13812
|
+
"--name-only",
|
|
13813
|
+
firstParent,
|
|
13814
|
+
mergeCommit.sha
|
|
13815
|
+
]);
|
|
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
|
+
}
|
|
13824
|
+
if (files.length === 0) return null;
|
|
13825
|
+
const diff = await this.git.exec([
|
|
13826
|
+
"diff",
|
|
13827
|
+
firstParent,
|
|
13828
|
+
mergeCommit.sha,
|
|
13829
|
+
"--",
|
|
13830
|
+
...files
|
|
13831
|
+
]);
|
|
13832
|
+
if (!diff.trim()) return null;
|
|
13833
|
+
const contentHash = this.computeContentHash(diff);
|
|
13834
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
13835
|
+
return null;
|
|
13836
|
+
}
|
|
13837
|
+
return {
|
|
13838
|
+
id: `patch-merge-${mergeCommit.sha.slice(0, 8)}`,
|
|
13839
|
+
content_hash: contentHash,
|
|
13840
|
+
original_commit: mergeCommit.sha,
|
|
13841
|
+
original_message: mergeCommit.message,
|
|
13842
|
+
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
13843
|
+
base_generation: lastGen.commit_sha,
|
|
13844
|
+
files,
|
|
13845
|
+
patch_content: diff,
|
|
13846
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
13847
|
+
};
|
|
13848
|
+
}
|
|
13461
13849
|
/**
|
|
13462
13850
|
* Detect patches via tree diff for non-linear history. Returns a composite patch.
|
|
13463
13851
|
* Revert reconciliation is skipped here because tree-diff produces a single composite
|
|
@@ -13469,7 +13857,14 @@ var ReplayDetector = class {
|
|
|
13469
13857
|
return this.detectPatchesViaCommitScan();
|
|
13470
13858
|
}
|
|
13471
13859
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
13472
|
-
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
|
+
}
|
|
13473
13868
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
13474
13869
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
13475
13870
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -13489,7 +13884,8 @@ var ReplayDetector = class {
|
|
|
13489
13884
|
// reference to find base file content. diffBase may be the tree_hash.
|
|
13490
13885
|
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
13491
13886
|
files,
|
|
13492
|
-
patch_content: diff
|
|
13887
|
+
patch_content: diff,
|
|
13888
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
13493
13889
|
};
|
|
13494
13890
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
13495
13891
|
}
|
|
@@ -13536,7 +13932,7 @@ var ReplayDetector = class {
|
|
|
13536
13932
|
} catch {
|
|
13537
13933
|
continue;
|
|
13538
13934
|
}
|
|
13539
|
-
|
|
13935
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
13540
13936
|
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
13541
13937
|
continue;
|
|
13542
13938
|
}
|
|
@@ -13544,7 +13940,26 @@ var ReplayDetector = class {
|
|
|
13544
13940
|
continue;
|
|
13545
13941
|
}
|
|
13546
13942
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
13547
|
-
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
|
+
}
|
|
13548
13963
|
if (files.length === 0) {
|
|
13549
13964
|
continue;
|
|
13550
13965
|
}
|
|
@@ -15456,6 +15871,20 @@ var ReplayApplicator = class {
|
|
|
15456
15871
|
resetAccumulator() {
|
|
15457
15872
|
this.fileTheirsAccumulator.clear();
|
|
15458
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
|
+
}
|
|
15459
15888
|
/**
|
|
15460
15889
|
* Apply all patches, returning results for each.
|
|
15461
15890
|
* Skips patches that match exclude patterns in replay.yml
|
|
@@ -15521,7 +15950,7 @@ var ReplayApplicator = class {
|
|
|
15521
15950
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
15522
15951
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
15523
15952
|
const effectiveTheirs = theirs ?? await (0, import_promises.readFile)((0, import_node_path3.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
15524
|
-
if (effectiveTheirs) {
|
|
15953
|
+
if (effectiveTheirs != null) {
|
|
15525
15954
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
15526
15955
|
content: effectiveTheirs,
|
|
15527
15956
|
baseGeneration: patch.base_generation
|
|
@@ -15545,16 +15974,20 @@ var ReplayApplicator = class {
|
|
|
15545
15974
|
return this.fileTheirsAccumulator.has(resolved);
|
|
15546
15975
|
})
|
|
15547
15976
|
).then((results) => results.some(Boolean));
|
|
15548
|
-
|
|
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) {
|
|
15549
15987
|
const snapshots = /* @__PURE__ */ new Map();
|
|
15550
|
-
const resolvedFiles = {};
|
|
15551
15988
|
for (const filePath of patch.files) {
|
|
15552
|
-
const
|
|
15553
|
-
|
|
15554
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
15555
|
-
}
|
|
15556
|
-
const fullPath = (0, import_node_path3.join)(this.outputDir, resolvedPath);
|
|
15557
|
-
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));
|
|
15558
15991
|
}
|
|
15559
15992
|
try {
|
|
15560
15993
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
@@ -15566,8 +15999,8 @@ var ReplayApplicator = class {
|
|
|
15566
15999
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
15567
16000
|
};
|
|
15568
16001
|
} catch {
|
|
15569
|
-
for (const [
|
|
15570
|
-
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);
|
|
15571
16004
|
if (content != null) {
|
|
15572
16005
|
await (0, import_promises.writeFile)(fullPath, content);
|
|
15573
16006
|
} else {
|
|
@@ -15687,7 +16120,7 @@ var ReplayApplicator = class {
|
|
|
15687
16120
|
}
|
|
15688
16121
|
let useAccumulatorAsMergeBase = false;
|
|
15689
16122
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
15690
|
-
if (accumulatorEntry && (
|
|
16123
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
15691
16124
|
theirs = await this.applyPatchToContent(
|
|
15692
16125
|
accumulatorEntry.content,
|
|
15693
16126
|
patch.patch_content,
|
|
@@ -15695,7 +16128,16 @@ var ReplayApplicator = class {
|
|
|
15695
16128
|
tempGit,
|
|
15696
16129
|
tempDir
|
|
15697
16130
|
);
|
|
15698
|
-
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;
|
|
15699
16141
|
useAccumulatorAsMergeBase = true;
|
|
15700
16142
|
}
|
|
15701
16143
|
}
|
|
@@ -15712,7 +16154,7 @@ var ReplayApplicator = class {
|
|
|
15712
16154
|
}
|
|
15713
16155
|
let effective_theirs = theirs;
|
|
15714
16156
|
let baseMismatchSkipped = false;
|
|
15715
|
-
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
16157
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
15716
16158
|
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
15717
16159
|
try {
|
|
15718
16160
|
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
@@ -15728,7 +16170,7 @@ var ReplayApplicator = class {
|
|
|
15728
16170
|
baseMismatchSkipped = true;
|
|
15729
16171
|
}
|
|
15730
16172
|
}
|
|
15731
|
-
if (base == null &&
|
|
16173
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
15732
16174
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
15733
16175
|
content: effective_theirs,
|
|
15734
16176
|
baseGeneration: patch.base_generation
|
|
@@ -15738,7 +16180,7 @@ var ReplayApplicator = class {
|
|
|
15738
16180
|
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
15739
16181
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
15740
16182
|
}
|
|
15741
|
-
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
16183
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
15742
16184
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
15743
16185
|
const outDir2 = (0, import_node_path3.dirname)(oursPath);
|
|
15744
16186
|
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
@@ -15752,16 +16194,20 @@ var ReplayApplicator = class {
|
|
|
15752
16194
|
conflictMetadata: metadata
|
|
15753
16195
|
};
|
|
15754
16196
|
}
|
|
16197
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
16198
|
+
content: merged2.content,
|
|
16199
|
+
baseGeneration: patch.base_generation
|
|
16200
|
+
});
|
|
15755
16201
|
return { file: resolvedPath, status: "merged" };
|
|
15756
16202
|
}
|
|
15757
|
-
if (
|
|
16203
|
+
if (effective_theirs == null) {
|
|
15758
16204
|
return {
|
|
15759
16205
|
file: resolvedPath,
|
|
15760
16206
|
status: "skipped",
|
|
15761
16207
|
reason: "missing-content"
|
|
15762
16208
|
};
|
|
15763
16209
|
}
|
|
15764
|
-
if (base == null && !useAccumulatorAsMergeBase ||
|
|
16210
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
15765
16211
|
return {
|
|
15766
16212
|
file: resolvedPath,
|
|
15767
16213
|
status: "skipped",
|
|
@@ -15780,7 +16226,7 @@ var ReplayApplicator = class {
|
|
|
15780
16226
|
const outDir = (0, import_node_path3.dirname)(oursPath);
|
|
15781
16227
|
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
15782
16228
|
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
15783
|
-
if (effective_theirs && !merged.hasConflicts) {
|
|
16229
|
+
if (effective_theirs != null && !merged.hasConflicts) {
|
|
15784
16230
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
15785
16231
|
content: effective_theirs,
|
|
15786
16232
|
baseGeneration: patch.base_generation
|
|
@@ -15878,6 +16324,28 @@ var ReplayApplicator = class {
|
|
|
15878
16324
|
return null;
|
|
15879
16325
|
}
|
|
15880
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
|
+
}
|
|
15881
16349
|
extractFileDiff(patchContent, filePath) {
|
|
15882
16350
|
const lines = patchContent.split("\n");
|
|
15883
16351
|
const diffLines = [];
|
|
@@ -16042,6 +16510,33 @@ var ReplayService = class {
|
|
|
16042
16510
|
committer;
|
|
16043
16511
|
lockManager;
|
|
16044
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
|
+
}
|
|
16045
16540
|
constructor(outputDir, _config) {
|
|
16046
16541
|
const git = new GitClient(outputDir);
|
|
16047
16542
|
this.git = git;
|
|
@@ -16052,6 +16547,7 @@ var ReplayService = class {
|
|
|
16052
16547
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
16053
16548
|
}
|
|
16054
16549
|
async runReplay(options) {
|
|
16550
|
+
this.warnings = [];
|
|
16055
16551
|
if (options?.skipApplication) {
|
|
16056
16552
|
return this.handleSkipApplication(options);
|
|
16057
16553
|
}
|
|
@@ -16125,6 +16621,7 @@ var ReplayService = class {
|
|
|
16125
16621
|
);
|
|
16126
16622
|
}
|
|
16127
16623
|
this.lockManager.save();
|
|
16624
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
16128
16625
|
}
|
|
16129
16626
|
determineFlow() {
|
|
16130
16627
|
try {
|
|
@@ -16191,6 +16688,7 @@ var ReplayService = class {
|
|
|
16191
16688
|
}
|
|
16192
16689
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
16193
16690
|
this.lockManager.save();
|
|
16691
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
16194
16692
|
if (!options?.stageOnly) {
|
|
16195
16693
|
await this.committer.commitReplay(0);
|
|
16196
16694
|
} else {
|
|
@@ -16202,13 +16700,15 @@ var ReplayService = class {
|
|
|
16202
16700
|
patchesApplied: 0,
|
|
16203
16701
|
patchesWithConflicts: 0,
|
|
16204
16702
|
patchesSkipped: 0,
|
|
16205
|
-
conflicts: []
|
|
16703
|
+
conflicts: [],
|
|
16704
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
16206
16705
|
};
|
|
16207
16706
|
}
|
|
16208
16707
|
async handleNoPatchesRegeneration(options) {
|
|
16209
|
-
|
|
16210
|
-
const
|
|
16708
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
16709
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
16211
16710
|
if (options?.dryRun) {
|
|
16711
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
16212
16712
|
return {
|
|
16213
16713
|
flow: "no-patches",
|
|
16214
16714
|
patchesDetected: newPatches.length,
|
|
@@ -16218,9 +16718,26 @@ var ReplayService = class {
|
|
|
16218
16718
|
patchesReverted: revertedPatchIds.length,
|
|
16219
16719
|
conflicts: [],
|
|
16220
16720
|
wouldApply: newPatches,
|
|
16221
|
-
warnings:
|
|
16721
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
16222
16722
|
};
|
|
16223
16723
|
}
|
|
16724
|
+
{
|
|
16725
|
+
const preLock = this.lockManager.read();
|
|
16726
|
+
const preCurrentGen = preLock.current_generation;
|
|
16727
|
+
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
16728
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
16729
|
+
for (const file of uniqueFiles) {
|
|
16730
|
+
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
16731
|
+
if (diff !== null && !diff.trim()) {
|
|
16732
|
+
absorbedFiles.add(file);
|
|
16733
|
+
}
|
|
16734
|
+
}
|
|
16735
|
+
if (absorbedFiles.size > 0) {
|
|
16736
|
+
newPatches = newPatches.filter(
|
|
16737
|
+
(p) => p.files.length === 0 || !p.files.every((f) => absorbedFiles.has(f))
|
|
16738
|
+
);
|
|
16739
|
+
}
|
|
16740
|
+
}
|
|
16224
16741
|
for (const id of revertedPatchIds) {
|
|
16225
16742
|
try {
|
|
16226
16743
|
this.lockManager.removePatch(id);
|
|
@@ -16239,6 +16756,7 @@ var ReplayService = class {
|
|
|
16239
16756
|
let results = [];
|
|
16240
16757
|
if (newPatches.length > 0) {
|
|
16241
16758
|
results = await this.applicator.applyPatches(newPatches);
|
|
16759
|
+
this._lastApplyResults = results;
|
|
16242
16760
|
this.revertConflictingFiles(results);
|
|
16243
16761
|
for (const result of results) {
|
|
16244
16762
|
if (result.status === "conflict") {
|
|
@@ -16253,6 +16771,7 @@ var ReplayService = class {
|
|
|
16253
16771
|
}
|
|
16254
16772
|
}
|
|
16255
16773
|
this.lockManager.save();
|
|
16774
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
16256
16775
|
if (newPatches.length > 0) {
|
|
16257
16776
|
if (!options?.stageOnly) {
|
|
16258
16777
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
@@ -16261,13 +16780,14 @@ var ReplayService = class {
|
|
|
16261
16780
|
await this.committer.stageAll();
|
|
16262
16781
|
}
|
|
16263
16782
|
}
|
|
16783
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
16264
16784
|
return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
|
|
16265
16785
|
}
|
|
16266
16786
|
async handleNormalRegeneration(options) {
|
|
16267
16787
|
if (options?.dryRun) {
|
|
16268
16788
|
const existingPatches2 = this.lockManager.getPatches();
|
|
16269
16789
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
16270
|
-
const warnings2 = [...this.detector.warnings];
|
|
16790
|
+
const warnings2 = [...this.detector.warnings, ...this.warnings];
|
|
16271
16791
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
16272
16792
|
return {
|
|
16273
16793
|
flow: "normal-regeneration",
|
|
@@ -16299,7 +16819,7 @@ var ReplayService = class {
|
|
|
16299
16819
|
}
|
|
16300
16820
|
existingPatches = this.lockManager.getPatches();
|
|
16301
16821
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
16302
|
-
const
|
|
16822
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
16303
16823
|
if (removedByPreRebase.length > 0) {
|
|
16304
16824
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
16305
16825
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -16331,6 +16851,7 @@ var ReplayService = class {
|
|
|
16331
16851
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
16332
16852
|
this.lockManager.addGeneration(genRecord);
|
|
16333
16853
|
const results = await this.applicator.applyPatches(allPatches);
|
|
16854
|
+
this._lastApplyResults = results;
|
|
16334
16855
|
this.revertConflictingFiles(results);
|
|
16335
16856
|
for (const result of results) {
|
|
16336
16857
|
if (result.status === "conflict") {
|
|
@@ -16352,12 +16873,14 @@ var ReplayService = class {
|
|
|
16352
16873
|
}
|
|
16353
16874
|
}
|
|
16354
16875
|
this.lockManager.save();
|
|
16876
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
16355
16877
|
if (options?.stageOnly) {
|
|
16356
16878
|
await this.committer.stageAll();
|
|
16357
16879
|
} else {
|
|
16358
16880
|
const appliedCount = results.filter((r) => r.status === "applied").length;
|
|
16359
16881
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
16360
16882
|
}
|
|
16883
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
16361
16884
|
return this.buildReport(
|
|
16362
16885
|
"normal-regeneration",
|
|
16363
16886
|
allPatches,
|
|
@@ -16381,6 +16904,7 @@ var ReplayService = class {
|
|
|
16381
16904
|
let keptAsUserOwned = 0;
|
|
16382
16905
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
16383
16906
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
16907
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
16384
16908
|
for (const result of results) {
|
|
16385
16909
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
16386
16910
|
const patch = result.patch;
|
|
@@ -16401,34 +16925,78 @@ var ReplayService = class {
|
|
|
16401
16925
|
const patch = result.patch;
|
|
16402
16926
|
if (patch.base_generation === currentGenSha) continue;
|
|
16403
16927
|
try {
|
|
16404
|
-
const
|
|
16405
|
-
|
|
16928
|
+
const originalByResolved = {};
|
|
16929
|
+
if (result.resolvedFiles) {
|
|
16930
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
16931
|
+
originalByResolved[resolved] = orig;
|
|
16932
|
+
}
|
|
16933
|
+
}
|
|
16934
|
+
const isUserOwnedPerFile = await Promise.all(
|
|
16406
16935
|
patch.files.map(async (file) => {
|
|
16407
|
-
if (file === ".fernignore")
|
|
16936
|
+
if (file === ".fernignore") return true;
|
|
16937
|
+
if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
|
|
16408
16938
|
return true;
|
|
16409
16939
|
}
|
|
16410
|
-
if (
|
|
16940
|
+
if (patch.user_owned) return true;
|
|
16941
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
16411
16942
|
return true;
|
|
16412
16943
|
}
|
|
16413
16944
|
const content = await this.git.showFile(currentGenSha, file);
|
|
16414
16945
|
return content === null;
|
|
16415
16946
|
})
|
|
16416
16947
|
);
|
|
16417
|
-
const
|
|
16418
|
-
|
|
16419
|
-
|
|
16420
|
-
|
|
16421
|
-
|
|
16948
|
+
const hasProtectedFiles = patch.files.some(
|
|
16949
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
|
|
16950
|
+
);
|
|
16951
|
+
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
16952
|
+
if (allUserOwned && patch.files.length > 0) {
|
|
16953
|
+
const baseChanged = patch.base_generation !== currentGenSha;
|
|
16954
|
+
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
16955
|
+
if (userDiff != null && userDiff.trim()) {
|
|
16956
|
+
const newHash = this.detector.computeContentHash(userDiff);
|
|
16957
|
+
patch.patch_content = userDiff;
|
|
16958
|
+
patch.content_hash = newHash;
|
|
16959
|
+
}
|
|
16960
|
+
patch.base_generation = currentGenSha;
|
|
16961
|
+
try {
|
|
16962
|
+
this.lockManager.updatePatch(patch.id, {
|
|
16963
|
+
base_generation: currentGenSha,
|
|
16964
|
+
...!patch.user_owned ? { user_owned: true } : {},
|
|
16965
|
+
...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
|
|
16966
|
+
});
|
|
16967
|
+
} catch {
|
|
16968
|
+
}
|
|
16969
|
+
patch.user_owned = true;
|
|
16422
16970
|
keptAsUserOwned++;
|
|
16971
|
+
if (baseChanged) repointed++;
|
|
16423
16972
|
continue;
|
|
16424
16973
|
}
|
|
16425
16974
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
16426
|
-
if (
|
|
16975
|
+
if (diff === null) continue;
|
|
16976
|
+
if (!diff.trim()) {
|
|
16977
|
+
if (hasProtectedFiles) {
|
|
16978
|
+
patch.base_generation = currentGenSha;
|
|
16979
|
+
try {
|
|
16980
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
16981
|
+
} catch {
|
|
16982
|
+
}
|
|
16983
|
+
keptAsUserOwned++;
|
|
16984
|
+
continue;
|
|
16985
|
+
}
|
|
16427
16986
|
this.lockManager.removePatch(patch.id);
|
|
16428
16987
|
absorbedPatchIds.add(patch.id);
|
|
16429
16988
|
absorbed++;
|
|
16430
16989
|
continue;
|
|
16431
16990
|
}
|
|
16991
|
+
if (hasProtectedFiles) {
|
|
16992
|
+
patch.base_generation = currentGenSha;
|
|
16993
|
+
try {
|
|
16994
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
16995
|
+
} catch {
|
|
16996
|
+
}
|
|
16997
|
+
keptAsUserOwned++;
|
|
16998
|
+
continue;
|
|
16999
|
+
}
|
|
16432
17000
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
16433
17001
|
if (seenContentHashes.has(newContentHash)) {
|
|
16434
17002
|
this.lockManager.removePatch(patch.id);
|
|
@@ -16437,17 +17005,89 @@ var ReplayService = class {
|
|
|
16437
17005
|
continue;
|
|
16438
17006
|
}
|
|
16439
17007
|
seenContentHashes.add(newContentHash);
|
|
16440
|
-
|
|
16441
|
-
|
|
16442
|
-
|
|
16443
|
-
|
|
16444
|
-
|
|
17008
|
+
patch.base_generation = currentGenSha;
|
|
17009
|
+
patch.patch_content = diff;
|
|
17010
|
+
patch.content_hash = newContentHash;
|
|
17011
|
+
try {
|
|
17012
|
+
this.lockManager.updatePatch(patch.id, {
|
|
17013
|
+
base_generation: currentGenSha,
|
|
17014
|
+
patch_content: diff,
|
|
17015
|
+
content_hash: newContentHash
|
|
17016
|
+
});
|
|
17017
|
+
} catch {
|
|
17018
|
+
}
|
|
16445
17019
|
contentRebased++;
|
|
16446
17020
|
} catch {
|
|
16447
17021
|
}
|
|
16448
17022
|
}
|
|
16449
17023
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
16450
17024
|
}
|
|
17025
|
+
/**
|
|
17026
|
+
* Determine whether `file` is user-owned (never produced by the generator).
|
|
17027
|
+
*
|
|
17028
|
+
* Resolution order (first match wins):
|
|
17029
|
+
* 1. `patch.user_owned === true` — authoritative, persisted across cycles.
|
|
17030
|
+
* 2. `.fernignore` itself, or file matches a .fernignore pattern.
|
|
17031
|
+
* 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
|
|
17032
|
+
* creation. Works for patches that predate the `user_owned` field or
|
|
17033
|
+
* whose flag was lost. Immune to rename tricks — relies on raw patch text.
|
|
17034
|
+
* 4. Tree-based check against `patch.base_generation` when it is reachable
|
|
17035
|
+
* AND distinct from `currentGenSha`. If the base tree is unreachable
|
|
17036
|
+
* (shallow clone / aggressive `git gc --prune`), emit a one-time
|
|
17037
|
+
* customer-actionable warning (deduplicated via warnedGens) and
|
|
17038
|
+
* conservatively treat as user-owned — strictly safer than silent
|
|
17039
|
+
* absorption (FER-9809).
|
|
17040
|
+
* 5. Final fallback — check `currentGenSha` when there is no usable base
|
|
17041
|
+
* (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
|
|
17042
|
+
*
|
|
17043
|
+
* `originalFileName` is the pre-rename path when the generator moved the
|
|
17044
|
+
* file between the patch's base and the current generation. Tree lookups
|
|
17045
|
+
* at ancestor generations use the original path — that's where the
|
|
17046
|
+
* generator produced the content.
|
|
17047
|
+
*/
|
|
17048
|
+
async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
|
|
17049
|
+
if (patch.user_owned) return true;
|
|
17050
|
+
if (file === ".fernignore") return true;
|
|
17051
|
+
if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) return true;
|
|
17052
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
17053
|
+
return true;
|
|
17054
|
+
}
|
|
17055
|
+
const base = patch.base_generation;
|
|
17056
|
+
if (base && base !== currentGenSha) {
|
|
17057
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
17058
|
+
if (!reachable) {
|
|
17059
|
+
if (!warnedGens.has(base)) {
|
|
17060
|
+
warnedGens.add(base);
|
|
17061
|
+
this.warnings.push(
|
|
17062
|
+
`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.`
|
|
17063
|
+
);
|
|
17064
|
+
}
|
|
17065
|
+
return true;
|
|
17066
|
+
}
|
|
17067
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
17068
|
+
}
|
|
17069
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
17070
|
+
}
|
|
17071
|
+
/**
|
|
17072
|
+
* Returns true if `file`'s diff section in `patchContent` starts with
|
|
17073
|
+
* `--- /dev/null`, meaning this file was introduced by the patch (user
|
|
17074
|
+
* creation). Used as a legacy-safe fallback when `patch.user_owned` is
|
|
17075
|
+
* absent or cleared.
|
|
17076
|
+
*/
|
|
17077
|
+
fileIsCreationInPatchContent(patchContent, file) {
|
|
17078
|
+
const lines = patchContent.split("\n");
|
|
17079
|
+
let inFileSection = false;
|
|
17080
|
+
for (const line of lines) {
|
|
17081
|
+
if (line.startsWith("diff --git ")) {
|
|
17082
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
17083
|
+
continue;
|
|
17084
|
+
}
|
|
17085
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
17086
|
+
return line === "--- /dev/null";
|
|
17087
|
+
}
|
|
17088
|
+
}
|
|
17089
|
+
return false;
|
|
17090
|
+
}
|
|
16451
17091
|
/**
|
|
16452
17092
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
16453
17093
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -16461,13 +17101,14 @@ var ReplayService = class {
|
|
|
16461
17101
|
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
16462
17102
|
if (cleanFiles.length === 0) return;
|
|
16463
17103
|
const patch = result.patch;
|
|
17104
|
+
if (patch.user_owned) return;
|
|
16464
17105
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
17106
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
16465
17107
|
const generatorCleanFiles = [];
|
|
16466
17108
|
for (const file of cleanFiles) {
|
|
16467
|
-
if (file
|
|
16468
|
-
|
|
16469
|
-
|
|
16470
|
-
if (content === null) continue;
|
|
17109
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
17110
|
+
continue;
|
|
17111
|
+
}
|
|
16471
17112
|
generatorCleanFiles.push(file);
|
|
16472
17113
|
}
|
|
16473
17114
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -16504,7 +17145,35 @@ var ReplayService = class {
|
|
|
16504
17145
|
let conflictResolved = 0;
|
|
16505
17146
|
let conflictAbsorbed = 0;
|
|
16506
17147
|
let contentRefreshed = 0;
|
|
17148
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
17149
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
17150
|
+
const reachabilityCache = /* @__PURE__ */ new Map();
|
|
17151
|
+
const isReachable = async (sha) => {
|
|
17152
|
+
const cached = reachabilityCache.get(sha);
|
|
17153
|
+
if (cached !== void 0) return cached;
|
|
17154
|
+
const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
|
|
17155
|
+
reachabilityCache.set(sha, reachable);
|
|
17156
|
+
return reachable;
|
|
17157
|
+
};
|
|
17158
|
+
const allPatchFilesUserOwned = async (patch) => {
|
|
17159
|
+
if (patch.user_owned) return true;
|
|
17160
|
+
if (patch.files.length === 0) return false;
|
|
17161
|
+
for (const file of patch.files) {
|
|
17162
|
+
if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
|
|
17163
|
+
return false;
|
|
17164
|
+
}
|
|
17165
|
+
}
|
|
17166
|
+
return true;
|
|
17167
|
+
};
|
|
16507
17168
|
for (const patch of patches) {
|
|
17169
|
+
if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
|
|
17170
|
+
if (!await isReachable(patch.base_generation)) {
|
|
17171
|
+
warnedGens.add(patch.base_generation);
|
|
17172
|
+
this.warnings.push(
|
|
17173
|
+
`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.`
|
|
17174
|
+
);
|
|
17175
|
+
}
|
|
17176
|
+
}
|
|
16508
17177
|
if (patch.status != null) {
|
|
16509
17178
|
delete patch.status;
|
|
16510
17179
|
continue;
|
|
@@ -16514,6 +17183,7 @@ var ReplayService = class {
|
|
|
16514
17183
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
16515
17184
|
if (diff === null) continue;
|
|
16516
17185
|
if (!diff.trim()) {
|
|
17186
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
16517
17187
|
this.lockManager.removePatch(patch.id);
|
|
16518
17188
|
contentRefreshed++;
|
|
16519
17189
|
continue;
|
|
@@ -16525,6 +17195,12 @@ var ReplayService = class {
|
|
|
16525
17195
|
if (hasStaleMarkers) {
|
|
16526
17196
|
continue;
|
|
16527
17197
|
}
|
|
17198
|
+
const isProtected = patch.files.some(
|
|
17199
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
|
|
17200
|
+
);
|
|
17201
|
+
if (isProtected) {
|
|
17202
|
+
continue;
|
|
17203
|
+
}
|
|
16528
17204
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
16529
17205
|
if (newContentHash !== patch.content_hash) {
|
|
16530
17206
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -16550,6 +17226,13 @@ var ReplayService = class {
|
|
|
16550
17226
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
16551
17227
|
if (diff === null) continue;
|
|
16552
17228
|
if (!diff.trim()) {
|
|
17229
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
17230
|
+
try {
|
|
17231
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
17232
|
+
} catch {
|
|
17233
|
+
}
|
|
17234
|
+
continue;
|
|
17235
|
+
}
|
|
16553
17236
|
this.lockManager.removePatch(patch.id);
|
|
16554
17237
|
conflictAbsorbed++;
|
|
16555
17238
|
continue;
|
|
@@ -17060,7 +17743,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
17060
17743
|
(0, import_node_fs4.writeFileSync)(gitattributesPath, content, "utf-8");
|
|
17061
17744
|
}
|
|
17062
17745
|
function computeContentHash(patchContent) {
|
|
17063
|
-
const
|
|
17746
|
+
const lines = patchContent.split("\n");
|
|
17747
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
17748
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
17749
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
17064
17750
|
return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
17065
17751
|
}
|
|
17066
17752
|
|