@fern-api/generator-cli 0.9.8 → 0.9.10
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/api.js +55 -18
- package/dist/cli.js +644 -86
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -10377,13 +10377,17 @@ var GithubStep = class extends BaseStep {
|
|
|
10377
10377
|
});
|
|
10378
10378
|
switch (branchAction) {
|
|
10379
10379
|
case "replay-branch":
|
|
10380
|
-
|
|
10381
|
-
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10380
|
+
if (shouldPushGenerationBaseTag(replayResult)) {
|
|
10381
|
+
generationBaseSha = await createReplayBranch(
|
|
10382
|
+
repository,
|
|
10383
|
+
prBranch,
|
|
10384
|
+
this.config.commitMessage,
|
|
10385
|
+
replayConflictInfo,
|
|
10386
|
+
this.logger
|
|
10387
|
+
);
|
|
10388
|
+
} else {
|
|
10389
|
+
await repository.createBranchFromHead(prBranch);
|
|
10390
|
+
}
|
|
10387
10391
|
break;
|
|
10388
10392
|
case "create-from-head":
|
|
10389
10393
|
await repository.createBranchFromHead(prBranch);
|
|
@@ -10592,6 +10596,9 @@ _Fern Run ID: \`${config.runId}\`_`;
|
|
|
10592
10596
|
function shouldEnableAutomerge(config) {
|
|
10593
10597
|
return config.automationMode === true && config.autoMerge === true && config.hasBreakingChanges !== true;
|
|
10594
10598
|
}
|
|
10599
|
+
function shouldPushGenerationBaseTag(replayResult) {
|
|
10600
|
+
return (replayResult?.patchesWithConflicts ?? 0) > 0;
|
|
10601
|
+
}
|
|
10595
10602
|
function resolveBranchAction({
|
|
10596
10603
|
automationMode,
|
|
10597
10604
|
skipCommit,
|
|
@@ -10730,18 +10737,24 @@ async function replayRun(params) {
|
|
|
10730
10737
|
}
|
|
10731
10738
|
}
|
|
10732
10739
|
if (shouldSync && tagSha != null) {
|
|
10733
|
-
|
|
10734
|
-
|
|
10735
|
-
|
|
10736
|
-
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
|
|
10740
|
-
|
|
10741
|
-
|
|
10742
|
-
|
|
10740
|
+
if (!isTagMergedIntoHead(outputDir, tagSha)) {
|
|
10741
|
+
logger?.warn(
|
|
10742
|
+
`fern-generation-base tag ${tagSha} is not reachable from HEAD \u2014 skipping divergent-merge sync. The tag likely points at an unmerged generation (PR closed without merge).`
|
|
10743
|
+
);
|
|
10744
|
+
} else {
|
|
10745
|
+
const syncService = new import_replay.ReplayService(outputDir, { enabled: true });
|
|
10746
|
+
await syncService.syncFromDivergentMerge(tagSha, {
|
|
10747
|
+
cliVersion,
|
|
10748
|
+
generatorVersions,
|
|
10749
|
+
baseBranchHead: baseBranchHead ?? void 0
|
|
10750
|
+
});
|
|
10751
|
+
try {
|
|
10752
|
+
const freshLockManager = new import_replay.LockfileManager(outputDir);
|
|
10753
|
+
if (freshLockManager.exists()) {
|
|
10754
|
+
previousGenerationSha = freshLockManager.read().current_generation;
|
|
10755
|
+
}
|
|
10756
|
+
} catch {
|
|
10743
10757
|
}
|
|
10744
|
-
} catch {
|
|
10745
10758
|
}
|
|
10746
10759
|
}
|
|
10747
10760
|
}
|
|
@@ -10791,6 +10804,30 @@ async function replayRun(params) {
|
|
|
10791
10804
|
baseBranchHead: resolvedBaseBranchHead
|
|
10792
10805
|
};
|
|
10793
10806
|
}
|
|
10807
|
+
function isTagMergedIntoHead(cwd2, tagSha) {
|
|
10808
|
+
try {
|
|
10809
|
+
(0, import_child_process3.execFileSync)("git", ["merge-base", "--is-ancestor", tagSha, "HEAD"], {
|
|
10810
|
+
cwd: cwd2,
|
|
10811
|
+
stdio: "pipe"
|
|
10812
|
+
});
|
|
10813
|
+
return true;
|
|
10814
|
+
} catch {
|
|
10815
|
+
}
|
|
10816
|
+
const tagTree = gitRevParse(cwd2, `${tagSha}^{tree}`);
|
|
10817
|
+
if (tagTree == null) {
|
|
10818
|
+
return false;
|
|
10819
|
+
}
|
|
10820
|
+
try {
|
|
10821
|
+
const trees = (0, import_child_process3.execFileSync)("git", ["log", "HEAD", "--format=%T", "--max-count=500"], {
|
|
10822
|
+
cwd: cwd2,
|
|
10823
|
+
encoding: "utf-8",
|
|
10824
|
+
stdio: "pipe"
|
|
10825
|
+
});
|
|
10826
|
+
return trees.split("\n").some((t2) => t2.trim() === tagTree);
|
|
10827
|
+
} catch {
|
|
10828
|
+
return false;
|
|
10829
|
+
}
|
|
10830
|
+
}
|
|
10794
10831
|
function gitRevParse(cwd2, rev) {
|
|
10795
10832
|
try {
|
|
10796
10833
|
return (0, import_child_process3.execFileSync)("git", ["rev-parse", "--verify", rev], {
|
package/dist/cli.js
CHANGED
|
@@ -20098,9 +20098,10 @@ var init_diff3 = __esm({
|
|
|
20098
20098
|
}
|
|
20099
20099
|
});
|
|
20100
20100
|
|
|
20101
|
-
// ../../node_modules/.pnpm/@fern-api+replay@0.
|
|
20101
|
+
// ../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.js
|
|
20102
20102
|
var dist_exports = {};
|
|
20103
20103
|
__export(dist_exports, {
|
|
20104
|
+
CREDENTIAL_PATTERNS: () => CREDENTIAL_PATTERNS,
|
|
20104
20105
|
FERN_BOT_EMAIL: () => FERN_BOT_EMAIL2,
|
|
20105
20106
|
FERN_BOT_LOGIN: () => FERN_BOT_LOGIN2,
|
|
20106
20107
|
FERN_BOT_NAME: () => FERN_BOT_NAME2,
|
|
@@ -20112,15 +20113,18 @@ __export(dist_exports, {
|
|
|
20112
20113
|
ReplayCommitter: () => ReplayCommitter,
|
|
20113
20114
|
ReplayDetector: () => ReplayDetector,
|
|
20114
20115
|
ReplayService: () => ReplayService,
|
|
20116
|
+
SENSITIVE_FILE_PATTERNS: () => SENSITIVE_FILE_PATTERNS,
|
|
20115
20117
|
bootstrap: () => bootstrap,
|
|
20116
20118
|
forget: () => forget,
|
|
20117
20119
|
isGenerationCommit: () => isGenerationCommit,
|
|
20118
20120
|
isReplayCommit: () => isReplayCommit,
|
|
20119
20121
|
isRevertCommit: () => isRevertCommit,
|
|
20122
|
+
isSensitiveFile: () => isSensitiveFile,
|
|
20120
20123
|
parseRevertedMessage: () => parseRevertedMessage,
|
|
20121
20124
|
parseRevertedSha: () => parseRevertedSha,
|
|
20122
20125
|
reset: () => reset,
|
|
20123
20126
|
resolve: () => resolve7,
|
|
20127
|
+
scanForCredentials: () => scanForCredentials,
|
|
20124
20128
|
status: () => status,
|
|
20125
20129
|
threeWayMerge: () => threeWayMerge
|
|
20126
20130
|
});
|
|
@@ -20359,6 +20363,18 @@ function reconstructFromGhostPatch(fileDiff, ours) {
|
|
|
20359
20363
|
}
|
|
20360
20364
|
return assembleHybrid(located, oursLines);
|
|
20361
20365
|
}
|
|
20366
|
+
function isSensitiveFile(filePath) {
|
|
20367
|
+
return SENSITIVE_FILE_PATTERNS.some((re) => re.test(filePath));
|
|
20368
|
+
}
|
|
20369
|
+
function scanForCredentials(content) {
|
|
20370
|
+
const matches = [];
|
|
20371
|
+
for (const { name, re } of CREDENTIAL_PATTERNS) {
|
|
20372
|
+
if (re.test(content)) {
|
|
20373
|
+
matches.push(name);
|
|
20374
|
+
}
|
|
20375
|
+
}
|
|
20376
|
+
return matches;
|
|
20377
|
+
}
|
|
20362
20378
|
function isGenerationCommit(commit) {
|
|
20363
20379
|
const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
|
|
20364
20380
|
const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN2 || commit.authorEmail === FERN_BOT_EMAIL2 || commit.authorName === FERN_BOT_NAME2);
|
|
@@ -20382,6 +20398,37 @@ function parseRevertedMessage(subject) {
|
|
|
20382
20398
|
const match2 = subject.match(/^Revert "(.+)"$/);
|
|
20383
20399
|
return match2?.[1];
|
|
20384
20400
|
}
|
|
20401
|
+
function parsePatchFileHeaders(patchContent) {
|
|
20402
|
+
const results = [];
|
|
20403
|
+
let currentPath = null;
|
|
20404
|
+
let currentIsCreate = false;
|
|
20405
|
+
let currentIsDelete = false;
|
|
20406
|
+
const flush = () => {
|
|
20407
|
+
if (currentPath !== null) {
|
|
20408
|
+
results.push({ path: currentPath, isCreate: currentIsCreate, isDelete: currentIsDelete });
|
|
20409
|
+
}
|
|
20410
|
+
};
|
|
20411
|
+
for (const line of patchContent.split("\n")) {
|
|
20412
|
+
if (line.startsWith("diff --git ")) {
|
|
20413
|
+
flush();
|
|
20414
|
+
currentPath = null;
|
|
20415
|
+
currentIsCreate = false;
|
|
20416
|
+
currentIsDelete = false;
|
|
20417
|
+
const match2 = line.match(/^diff --git a\/(.+?) b\/(.+?)$/);
|
|
20418
|
+
if (match2) {
|
|
20419
|
+
currentPath = match2[2] ?? null;
|
|
20420
|
+
}
|
|
20421
|
+
} else if (currentPath !== null) {
|
|
20422
|
+
if (line.startsWith("new file mode ")) {
|
|
20423
|
+
currentIsCreate = true;
|
|
20424
|
+
} else if (line.startsWith("deleted file mode ")) {
|
|
20425
|
+
currentIsDelete = true;
|
|
20426
|
+
}
|
|
20427
|
+
}
|
|
20428
|
+
}
|
|
20429
|
+
flush();
|
|
20430
|
+
return results;
|
|
20431
|
+
}
|
|
20385
20432
|
function threeWayMerge(base, ours, theirs) {
|
|
20386
20433
|
const baseLines = base.split("\n");
|
|
20387
20434
|
const oursLines = ours.split("\n");
|
|
@@ -20771,7 +20818,10 @@ function ensureGitattributesEntries(outputDir) {
|
|
|
20771
20818
|
(0, import_fs9.writeFileSync)(gitattributesPath, content, "utf-8");
|
|
20772
20819
|
}
|
|
20773
20820
|
function computeContentHash(patchContent) {
|
|
20774
|
-
const
|
|
20821
|
+
const lines = patchContent.split("\n");
|
|
20822
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
20823
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
20824
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
20775
20825
|
return `sha256:${(0, import_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
20776
20826
|
}
|
|
20777
20827
|
function parseDiffStat(patchContent) {
|
|
@@ -21061,9 +21111,9 @@ function status(outputDir) {
|
|
|
21061
21111
|
excludePatterns
|
|
21062
21112
|
};
|
|
21063
21113
|
}
|
|
21064
|
-
var import_fs6, import_path8, import_yaml, import_crypto, import_promises4, import_os, import_path9, import_fs7, import_path10, import_crypto2, import_fs8, import_path11, import_yaml2, import_crypto3, import_fs9, import_path12, import_fs10, __defProp3, __getOwnPropNames3, __esm3, __export3, GitClient_exports, GitClient, init_GitClient, HybridReconstruction_exports, init_HybridReconstruction, FERN_BOT_NAME2, FERN_BOT_EMAIL2, FERN_BOT_LOGIN2, FERN_SUPPORT_NAMES, LOCKFILE_HEADER, LockfileNotFoundError, LockfileManager, INFRASTRUCTURE_FILES, ReplayDetector, CONFLICT_OPENER, CONFLICT_SEPARATOR, CONFLICT_CLOSER, BINARY_EXTENSIONS, ReplayApplicator, ReplayCommitter, ReplayService, FernignoreMigrator, REPLAY_FERNIGNORE_ENTRIES, GITATTRIBUTES_ENTRIES, EMPTY_RESULT;
|
|
21114
|
+
var import_fs6, import_path8, import_yaml, import_crypto, import_promises4, import_os, import_path9, import_fs7, import_path10, import_crypto2, import_fs8, import_path11, import_yaml2, import_crypto3, import_fs9, import_path12, import_fs10, __defProp3, __getOwnPropNames3, __esm3, __export3, GitClient_exports, GitClient, init_GitClient, HybridReconstruction_exports, init_HybridReconstruction, CREDENTIAL_PATTERNS, SENSITIVE_FILE_PATTERNS, FERN_BOT_NAME2, FERN_BOT_EMAIL2, FERN_BOT_LOGIN2, FERN_SUPPORT_NAMES, LOCKFILE_HEADER, LockfileNotFoundError, LockfileManager, INFRASTRUCTURE_FILES, ReplayDetector, CONFLICT_OPENER, CONFLICT_SEPARATOR, CONFLICT_CLOSER, BINARY_EXTENSIONS, ReplayApplicator, ReplayCommitter, ReplayService, FernignoreMigrator, REPLAY_FERNIGNORE_ENTRIES, GITATTRIBUTES_ENTRIES, EMPTY_RESULT;
|
|
21065
21115
|
var init_dist3 = __esm({
|
|
21066
|
-
"../../node_modules/.pnpm/@fern-api+replay@0.
|
|
21116
|
+
"../../node_modules/.pnpm/@fern-api+replay@0.11.0/node_modules/@fern-api/replay/dist/index.js"() {
|
|
21067
21117
|
"use strict";
|
|
21068
21118
|
init_esm4();
|
|
21069
21119
|
import_fs6 = require("fs");
|
|
@@ -21163,13 +21213,30 @@ var init_dist3 = __esm({
|
|
|
21163
21213
|
const output = await this.exec(["rev-parse", `${commitSha}^@`]);
|
|
21164
21214
|
return output.trim().split("\n").filter(Boolean);
|
|
21165
21215
|
}
|
|
21216
|
+
/**
|
|
21217
|
+
* Detects renames and copies between two trees.
|
|
21218
|
+
*
|
|
21219
|
+
* Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
|
|
21220
|
+
* Copies are treated as rename-equivalent because the generator may produce the
|
|
21221
|
+
* new path while the old path remains in the tree (e.g., imperfect cleanup,
|
|
21222
|
+
* customer edits at the old path, or test helpers that only write new files).
|
|
21223
|
+
* Callers that care about "is the source still present" should verify via
|
|
21224
|
+
* `showFile(toTree, from)`.
|
|
21225
|
+
*/
|
|
21166
21226
|
async detectRenames(fromTree, toTree) {
|
|
21167
21227
|
try {
|
|
21168
|
-
const output = await this.exec([
|
|
21228
|
+
const output = await this.exec([
|
|
21229
|
+
"diff",
|
|
21230
|
+
"--find-renames",
|
|
21231
|
+
"--find-copies",
|
|
21232
|
+
"--name-status",
|
|
21233
|
+
fromTree,
|
|
21234
|
+
toTree
|
|
21235
|
+
]);
|
|
21169
21236
|
const renames = [];
|
|
21170
21237
|
for (const line of output.trim().split("\n")) {
|
|
21171
21238
|
if (!line) continue;
|
|
21172
|
-
if (line.startsWith("R")) {
|
|
21239
|
+
if (line.startsWith("R") || line.startsWith("C")) {
|
|
21173
21240
|
const parts = line.split(" ");
|
|
21174
21241
|
if (parts.length >= 3) {
|
|
21175
21242
|
renames.push({ from: parts[1], to: parts[2] });
|
|
@@ -21226,6 +21293,25 @@ var init_dist3 = __esm({
|
|
|
21226
21293
|
"use strict";
|
|
21227
21294
|
}
|
|
21228
21295
|
});
|
|
21296
|
+
CREDENTIAL_PATTERNS = [
|
|
21297
|
+
{ name: "PRIVATE KEY block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
21298
|
+
{ name: "OPENSSH key", re: /-----BEGIN OPENSSH PRIVATE KEY-----/ },
|
|
21299
|
+
{ name: "RSA key", re: /-----BEGIN RSA PRIVATE KEY-----/ },
|
|
21300
|
+
{ name: "CERTIFICATE", re: /-----BEGIN CERTIFICATE-----/ },
|
|
21301
|
+
{ name: "TOKEN=", re: /\bTOKEN\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/ },
|
|
21302
|
+
{ name: "api_key=", re: /\bapi[_-]?key\s*=\s*["']?[A-Za-z0-9._\-+/=]{10,}/i },
|
|
21303
|
+
{ name: "password=", re: /\bpassword\s*=\s*["']?[^\s"']{6,}/i },
|
|
21304
|
+
{ name: "AWS_SECRET_ACCESS_KEY", re: /AWS_SECRET_ACCESS_KEY/ },
|
|
21305
|
+
{ name: "JWT (eyJ...)", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ }
|
|
21306
|
+
];
|
|
21307
|
+
SENSITIVE_FILE_PATTERNS = [
|
|
21308
|
+
/(^|\/)\.env(\.[^/]+)?$/,
|
|
21309
|
+
/\.pem$/,
|
|
21310
|
+
/\.key$/,
|
|
21311
|
+
/\.pkce_state\.json$/,
|
|
21312
|
+
/(^|\/)id_rsa$/,
|
|
21313
|
+
/(^|\/)id_ed25519$/
|
|
21314
|
+
];
|
|
21229
21315
|
init_GitClient();
|
|
21230
21316
|
FERN_BOT_NAME2 = "fern-api";
|
|
21231
21317
|
FERN_BOT_EMAIL2 = "115122769+fern-api[bot]@users.noreply.github.com";
|
|
@@ -21241,6 +21327,7 @@ var init_dist3 = __esm({
|
|
|
21241
21327
|
LockfileManager = class {
|
|
21242
21328
|
outputDir;
|
|
21243
21329
|
lock = null;
|
|
21330
|
+
warnings = [];
|
|
21244
21331
|
constructor(outputDir) {
|
|
21245
21332
|
this.outputDir = outputDir;
|
|
21246
21333
|
}
|
|
@@ -21289,6 +21376,27 @@ var init_dist3 = __esm({
|
|
|
21289
21376
|
if (!this.lock) {
|
|
21290
21377
|
throw new Error("No lockfile data to save. Call read() or initialize() first.");
|
|
21291
21378
|
}
|
|
21379
|
+
this.warnings.length = 0;
|
|
21380
|
+
const cleanPatches = [];
|
|
21381
|
+
for (const patch of this.lock.patches) {
|
|
21382
|
+
const credentialMatches = scanForCredentials(patch.patch_content);
|
|
21383
|
+
const sensitiveFiles = patch.files.filter((f2) => isSensitiveFile(f2));
|
|
21384
|
+
if (credentialMatches.length > 0 || sensitiveFiles.length > 0) {
|
|
21385
|
+
const reasons = [];
|
|
21386
|
+
if (credentialMatches.length > 0) {
|
|
21387
|
+
reasons.push(`credential patterns: ${credentialMatches.join(", ")}`);
|
|
21388
|
+
}
|
|
21389
|
+
if (sensitiveFiles.length > 0) {
|
|
21390
|
+
reasons.push(`sensitive files: ${sensitiveFiles.join(", ")}`);
|
|
21391
|
+
}
|
|
21392
|
+
this.warnings.push(
|
|
21393
|
+
`Patch ${patch.id} removed from lockfile: ${reasons.join("; ")}. This prevents credential exposure in committed lockfiles.`
|
|
21394
|
+
);
|
|
21395
|
+
} else {
|
|
21396
|
+
cleanPatches.push(patch);
|
|
21397
|
+
}
|
|
21398
|
+
}
|
|
21399
|
+
this.lock.patches = cleanPatches;
|
|
21292
21400
|
const dir = (0, import_path8.dirname)(this.lockfilePath);
|
|
21293
21401
|
if (!(0, import_fs6.existsSync)(dir)) {
|
|
21294
21402
|
(0, import_fs6.mkdirSync)(dir, { recursive: true });
|
|
@@ -21468,12 +21576,31 @@ var init_dist3 = __esm({
|
|
|
21468
21576
|
);
|
|
21469
21577
|
continue;
|
|
21470
21578
|
}
|
|
21471
|
-
|
|
21579
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
21472
21580
|
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
21473
21581
|
continue;
|
|
21474
21582
|
}
|
|
21475
21583
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
21476
|
-
const
|
|
21584
|
+
const allFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
|
|
21585
|
+
const sensitiveFiles = allFiles.filter((f2) => isSensitiveFile(f2));
|
|
21586
|
+
const files = allFiles.filter((f2) => !isSensitiveFile(f2));
|
|
21587
|
+
if (sensitiveFiles.length > 0) {
|
|
21588
|
+
this.warnings.push(
|
|
21589
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveFiles.join(", ")}. These files will not be tracked by replay.`
|
|
21590
|
+
);
|
|
21591
|
+
if (files.length > 0) {
|
|
21592
|
+
const parentSha = parents[0];
|
|
21593
|
+
if (parentSha) {
|
|
21594
|
+
try {
|
|
21595
|
+
patchContent = await this.git.exec(["diff", parentSha, commit.sha, "--", ...files]);
|
|
21596
|
+
} catch {
|
|
21597
|
+
continue;
|
|
21598
|
+
}
|
|
21599
|
+
if (!patchContent.trim()) continue;
|
|
21600
|
+
contentHash = this.computeContentHash(patchContent);
|
|
21601
|
+
}
|
|
21602
|
+
}
|
|
21603
|
+
}
|
|
21477
21604
|
if (files.length === 0) {
|
|
21478
21605
|
continue;
|
|
21479
21606
|
}
|
|
@@ -21485,14 +21612,16 @@ var init_dist3 = __esm({
|
|
|
21485
21612
|
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
21486
21613
|
base_generation: lastGen.commit_sha,
|
|
21487
21614
|
files,
|
|
21488
|
-
patch_content: patchContent
|
|
21615
|
+
patch_content: patchContent,
|
|
21616
|
+
...this.isCreationOnlyPatch(patchContent) ? { user_owned: true } : {}
|
|
21489
21617
|
});
|
|
21490
21618
|
}
|
|
21491
|
-
|
|
21619
|
+
const survivingPatches = await this.collapseNetZeroFiles(newPatches);
|
|
21620
|
+
survivingPatches.reverse();
|
|
21492
21621
|
const revertedPatchIdSet = /* @__PURE__ */ new Set();
|
|
21493
21622
|
const revertIndicesToRemove = /* @__PURE__ */ new Set();
|
|
21494
|
-
for (let i = 0; i <
|
|
21495
|
-
const patch =
|
|
21623
|
+
for (let i = 0; i < survivingPatches.length; i++) {
|
|
21624
|
+
const patch = survivingPatches[i];
|
|
21496
21625
|
if (!isRevertCommit(patch.original_message)) continue;
|
|
21497
21626
|
let body = "";
|
|
21498
21627
|
try {
|
|
@@ -21521,7 +21650,7 @@ var init_dist3 = __esm({
|
|
|
21521
21650
|
if (matchedExisting) continue;
|
|
21522
21651
|
let matchedNew = false;
|
|
21523
21652
|
if (revertedSha) {
|
|
21524
|
-
const idx =
|
|
21653
|
+
const idx = survivingPatches.findIndex(
|
|
21525
21654
|
(p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_commit === revertedSha
|
|
21526
21655
|
);
|
|
21527
21656
|
if (idx !== -1) {
|
|
@@ -21531,7 +21660,7 @@ var init_dist3 = __esm({
|
|
|
21531
21660
|
}
|
|
21532
21661
|
}
|
|
21533
21662
|
if (!matchedNew && revertedMessage) {
|
|
21534
|
-
const idx =
|
|
21663
|
+
const idx = survivingPatches.findIndex(
|
|
21535
21664
|
(p, j2) => j2 !== i && !revertIndicesToRemove.has(j2) && p.original_message === revertedMessage
|
|
21536
21665
|
);
|
|
21537
21666
|
if (idx !== -1) {
|
|
@@ -21543,18 +21672,107 @@ var init_dist3 = __esm({
|
|
|
21543
21672
|
revertIndicesToRemove.add(i);
|
|
21544
21673
|
}
|
|
21545
21674
|
}
|
|
21546
|
-
const filteredPatches =
|
|
21675
|
+
const filteredPatches = survivingPatches.filter((_2, i) => !revertIndicesToRemove.has(i));
|
|
21547
21676
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
21548
21677
|
}
|
|
21549
21678
|
/**
|
|
21550
21679
|
* Compute content hash for deduplication.
|
|
21551
|
-
*
|
|
21552
|
-
*
|
|
21680
|
+
*
|
|
21681
|
+
* Produces a format-agnostic hash so both `git format-patch` output
|
|
21682
|
+
* (email-wrapped) and plain `git diff` output hash to the same value
|
|
21683
|
+
* when the underlying diff hunks are identical.
|
|
21684
|
+
*
|
|
21685
|
+
* Normalization:
|
|
21686
|
+
* 1. Strip email wrapper: everything before the first `diff --git` line
|
|
21687
|
+
* (From:, Subject:, Date:, diffstat, blank separators).
|
|
21688
|
+
* 2. Strip `index` lines (blob SHA pairs change across rebases).
|
|
21689
|
+
* 3. Strip trailing git version marker (`-- \n<version>`).
|
|
21690
|
+
*
|
|
21691
|
+
* This ensures content hashes match across the no-patches and normal-
|
|
21692
|
+
* regeneration flows, which store formatPatch and git-diff formats
|
|
21693
|
+
* respectively (FER-9850, D5-D9).
|
|
21553
21694
|
*/
|
|
21554
21695
|
computeContentHash(patchContent) {
|
|
21555
|
-
const
|
|
21696
|
+
const lines = patchContent.split("\n");
|
|
21697
|
+
const diffStart = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
21698
|
+
const relevantLines = diffStart > 0 ? lines.slice(diffStart) : lines;
|
|
21699
|
+
const normalized = relevantLines.filter((line) => !line.startsWith("index ")).join("\n").replace(/\n-- \n[\d]+\.[\d]+[^\n]*\s*$/, "");
|
|
21556
21700
|
return `sha256:${(0, import_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
21557
21701
|
}
|
|
21702
|
+
/**
|
|
21703
|
+
* FER-9805 — Drop patches whose files were transiently created and then
|
|
21704
|
+
* deleted within the current linear detection window, and are absent from
|
|
21705
|
+
* HEAD at the end of the window.
|
|
21706
|
+
*
|
|
21707
|
+
* Rationale: on a merge commit, createMergeCompositePatch already diffs
|
|
21708
|
+
* firstParent..mergeCommit so net-zero files never enter the composite. On
|
|
21709
|
+
* linear history each commit becomes its own patch, so a file that was
|
|
21710
|
+
* briefly added and later removed survives as a create+delete pair whose
|
|
21711
|
+
* cumulative diff is empty. We drop such patches before they reach the
|
|
21712
|
+
* lockfile.
|
|
21713
|
+
*
|
|
21714
|
+
* A file is "transient" when:
|
|
21715
|
+
* 1. some patch in the window sets `new file mode` for it (a creation), AND
|
|
21716
|
+
* 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
|
|
21717
|
+
* 3. it is absent from HEAD's tree.
|
|
21718
|
+
*
|
|
21719
|
+
* The HEAD guard (#3) prevents false positives when a file is deleted
|
|
21720
|
+
* and then recreated with different content — the cumulative diff of
|
|
21721
|
+
* such a pair is non-empty and must be preserved.
|
|
21722
|
+
*
|
|
21723
|
+
* This only drops patches whose `files` are a subset of the transient
|
|
21724
|
+
* set. A partial-transient patch (one that touches a transient file
|
|
21725
|
+
* alongside a persistent one) is left unmodified; surgical stripping of
|
|
21726
|
+
* single files from a multi-file patch would require a follow-up that
|
|
21727
|
+
* regenerates the patch content via `git format-patch -- <files>`. No
|
|
21728
|
+
* current failing test exercises this, and leaving the patch intact
|
|
21729
|
+
* preserves today's behaviour. TODO(FER-9805): revisit if a future
|
|
21730
|
+
* adversarial test demands per-file stripping.
|
|
21731
|
+
*/
|
|
21732
|
+
async collapseNetZeroFiles(patches) {
|
|
21733
|
+
if (patches.length === 0) return patches;
|
|
21734
|
+
const stateByFile = /* @__PURE__ */ new Map();
|
|
21735
|
+
for (const patch of patches) {
|
|
21736
|
+
for (const header of parsePatchFileHeaders(patch.patch_content)) {
|
|
21737
|
+
if (!header.isCreate && !header.isDelete) continue;
|
|
21738
|
+
const prior = stateByFile.get(header.path) ?? { created: false, deleted: false };
|
|
21739
|
+
if (header.isCreate) prior.created = true;
|
|
21740
|
+
if (header.isDelete) prior.deleted = true;
|
|
21741
|
+
stateByFile.set(header.path, prior);
|
|
21742
|
+
}
|
|
21743
|
+
}
|
|
21744
|
+
let anyCandidate = false;
|
|
21745
|
+
for (const state of stateByFile.values()) {
|
|
21746
|
+
if (state.created && state.deleted) {
|
|
21747
|
+
anyCandidate = true;
|
|
21748
|
+
break;
|
|
21749
|
+
}
|
|
21750
|
+
}
|
|
21751
|
+
if (!anyCandidate) return patches;
|
|
21752
|
+
const headFiles = await this.listHeadFiles();
|
|
21753
|
+
const transient = /* @__PURE__ */ new Set();
|
|
21754
|
+
for (const [file, state] of stateByFile) {
|
|
21755
|
+
if (state.created && state.deleted && !headFiles.has(file)) {
|
|
21756
|
+
transient.add(file);
|
|
21757
|
+
}
|
|
21758
|
+
}
|
|
21759
|
+
if (transient.size === 0) return patches;
|
|
21760
|
+
return patches.filter((patch) => !patch.files.every((f2) => transient.has(f2)));
|
|
21761
|
+
}
|
|
21762
|
+
/**
|
|
21763
|
+
* List every tracked path at HEAD (one `git ls-tree -r` invocation).
|
|
21764
|
+
* Returns an empty Set if HEAD is unborn or the invocation fails — the
|
|
21765
|
+
* caller then treats every candidate as "not in HEAD" and may collapse
|
|
21766
|
+
* more aggressively. On typical SDK repos this is a single sub-10ms call.
|
|
21767
|
+
*/
|
|
21768
|
+
async listHeadFiles() {
|
|
21769
|
+
try {
|
|
21770
|
+
const out = await this.git.exec(["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
21771
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
21772
|
+
} catch {
|
|
21773
|
+
return /* @__PURE__ */ new Set();
|
|
21774
|
+
}
|
|
21775
|
+
}
|
|
21558
21776
|
/**
|
|
21559
21777
|
* Create a single composite patch for a merge commit by diffing the merge
|
|
21560
21778
|
* commit against its first parent. This captures the net change of the
|
|
@@ -21570,7 +21788,14 @@ var init_dist3 = __esm({
|
|
|
21570
21788
|
firstParent,
|
|
21571
21789
|
mergeCommit.sha
|
|
21572
21790
|
]);
|
|
21573
|
-
const
|
|
21791
|
+
const allMergeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
|
|
21792
|
+
const sensitiveMergeFiles = allMergeFiles.filter((f2) => isSensitiveFile(f2));
|
|
21793
|
+
const files = allMergeFiles.filter((f2) => !isSensitiveFile(f2));
|
|
21794
|
+
if (sensitiveMergeFiles.length > 0) {
|
|
21795
|
+
this.warnings.push(
|
|
21796
|
+
`Sensitive file(s) excluded from merge patch for commit ${mergeCommit.sha.slice(0, 7)}: ${sensitiveMergeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
21797
|
+
);
|
|
21798
|
+
}
|
|
21574
21799
|
if (files.length === 0) return null;
|
|
21575
21800
|
const diff = await this.git.exec([
|
|
21576
21801
|
"diff",
|
|
@@ -21592,7 +21817,8 @@ var init_dist3 = __esm({
|
|
|
21592
21817
|
original_author: `${mergeCommit.authorName} <${mergeCommit.authorEmail}>`,
|
|
21593
21818
|
base_generation: lastGen.commit_sha,
|
|
21594
21819
|
files,
|
|
21595
|
-
patch_content: diff
|
|
21820
|
+
patch_content: diff,
|
|
21821
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
21596
21822
|
};
|
|
21597
21823
|
}
|
|
21598
21824
|
/**
|
|
@@ -21606,7 +21832,14 @@ var init_dist3 = __esm({
|
|
|
21606
21832
|
return this.detectPatchesViaCommitScan();
|
|
21607
21833
|
}
|
|
21608
21834
|
const filesOutput = await this.git.exec(["diff", "--name-only", diffBase, "HEAD"]);
|
|
21609
|
-
const
|
|
21835
|
+
const allTreeFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2)).filter((f2) => !f2.startsWith(".fern/"));
|
|
21836
|
+
const sensitiveTreeFiles = allTreeFiles.filter((f2) => isSensitiveFile(f2));
|
|
21837
|
+
const files = allTreeFiles.filter((f2) => !isSensitiveFile(f2));
|
|
21838
|
+
if (sensitiveTreeFiles.length > 0) {
|
|
21839
|
+
this.warnings.push(
|
|
21840
|
+
`Sensitive file(s) excluded from composite patch: ${sensitiveTreeFiles.join(", ")}. These files will not be tracked by replay.`
|
|
21841
|
+
);
|
|
21842
|
+
}
|
|
21610
21843
|
if (files.length === 0) return { patches: [], revertedPatchIds: [] };
|
|
21611
21844
|
const diff = await this.git.exec(["diff", diffBase, "HEAD", "--", ...files]);
|
|
21612
21845
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
@@ -21626,7 +21859,8 @@ var init_dist3 = __esm({
|
|
|
21626
21859
|
// reference to find base file content. diffBase may be the tree_hash.
|
|
21627
21860
|
base_generation: commitKnownMissing ? diffBase : lastGen.commit_sha,
|
|
21628
21861
|
files,
|
|
21629
|
-
patch_content: diff
|
|
21862
|
+
patch_content: diff,
|
|
21863
|
+
...this.isCreationOnlyPatch(diff) ? { user_owned: true } : {}
|
|
21630
21864
|
};
|
|
21631
21865
|
return { patches: [compositePatch], revertedPatchIds: [] };
|
|
21632
21866
|
}
|
|
@@ -21673,7 +21907,7 @@ var init_dist3 = __esm({
|
|
|
21673
21907
|
} catch {
|
|
21674
21908
|
continue;
|
|
21675
21909
|
}
|
|
21676
|
-
|
|
21910
|
+
let contentHash = this.computeContentHash(patchContent);
|
|
21677
21911
|
if (existingHashes.has(contentHash) || forgottenHashes.has(contentHash)) {
|
|
21678
21912
|
continue;
|
|
21679
21913
|
}
|
|
@@ -21681,7 +21915,26 @@ var init_dist3 = __esm({
|
|
|
21681
21915
|
continue;
|
|
21682
21916
|
}
|
|
21683
21917
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
21684
|
-
const
|
|
21918
|
+
const allScanFiles = filesOutput.trim().split("\n").filter(Boolean).filter((f2) => !INFRASTRUCTURE_FILES.has(f2));
|
|
21919
|
+
const sensitiveScanFiles = allScanFiles.filter((f2) => isSensitiveFile(f2));
|
|
21920
|
+
const files = allScanFiles.filter((f2) => !isSensitiveFile(f2));
|
|
21921
|
+
if (sensitiveScanFiles.length > 0) {
|
|
21922
|
+
this.warnings.push(
|
|
21923
|
+
`Sensitive file(s) excluded from patch for commit ${commit.sha.slice(0, 7)}: ${sensitiveScanFiles.join(", ")}. These files will not be tracked by replay.`
|
|
21924
|
+
);
|
|
21925
|
+
if (files.length > 0) {
|
|
21926
|
+
if (parents.length === 0) {
|
|
21927
|
+
continue;
|
|
21928
|
+
}
|
|
21929
|
+
try {
|
|
21930
|
+
patchContent = await this.git.exec(["diff", parents[0], commit.sha, "--", ...files]);
|
|
21931
|
+
} catch {
|
|
21932
|
+
continue;
|
|
21933
|
+
}
|
|
21934
|
+
if (!patchContent.trim()) continue;
|
|
21935
|
+
contentHash = this.computeContentHash(patchContent);
|
|
21936
|
+
}
|
|
21937
|
+
}
|
|
21685
21938
|
if (files.length === 0) {
|
|
21686
21939
|
continue;
|
|
21687
21940
|
}
|
|
@@ -21828,6 +22081,20 @@ var init_dist3 = __esm({
|
|
|
21828
22081
|
resetAccumulator() {
|
|
21829
22082
|
this.fileTheirsAccumulator.clear();
|
|
21830
22083
|
}
|
|
22084
|
+
/**
|
|
22085
|
+
* @internal Test-only.
|
|
22086
|
+
* Returns a defensive copy of the inter-patch accumulator. Used by the
|
|
22087
|
+
* adversarial test suite (FER-9791) to assert invariant I2: after every
|
|
22088
|
+
* applied patch, the accumulator has an entry for each file the patch
|
|
22089
|
+
* touched, and the entry's content matches on-disk.
|
|
22090
|
+
*/
|
|
22091
|
+
getAccumulatorSnapshot() {
|
|
22092
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
22093
|
+
for (const [path5, entry] of this.fileTheirsAccumulator) {
|
|
22094
|
+
snapshot.set(path5, { content: entry.content, baseGeneration: entry.baseGeneration });
|
|
22095
|
+
}
|
|
22096
|
+
return snapshot;
|
|
22097
|
+
}
|
|
21831
22098
|
/**
|
|
21832
22099
|
* Apply all patches, returning results for each.
|
|
21833
22100
|
* Skips patches that match exclude patterns in replay.yml
|
|
@@ -21893,7 +22160,7 @@ var init_dist3 = __esm({
|
|
|
21893
22160
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
21894
22161
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
21895
22162
|
const effectiveTheirs = theirs ?? await (0, import_promises4.readFile)((0, import_path9.join)(this.outputDir, resolvedPath), "utf-8").catch(() => null);
|
|
21896
|
-
if (effectiveTheirs) {
|
|
22163
|
+
if (effectiveTheirs != null) {
|
|
21897
22164
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
21898
22165
|
content: effectiveTheirs,
|
|
21899
22166
|
baseGeneration: patch.base_generation
|
|
@@ -21917,16 +22184,20 @@ var init_dist3 = __esm({
|
|
|
21917
22184
|
return this.fileTheirsAccumulator.has(resolved);
|
|
21918
22185
|
})
|
|
21919
22186
|
).then((results) => results.some(Boolean));
|
|
21920
|
-
|
|
22187
|
+
const resolvedFiles = {};
|
|
22188
|
+
for (const filePath of patch.files) {
|
|
22189
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
22190
|
+
if (resolvedPath !== filePath) {
|
|
22191
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
22192
|
+
}
|
|
22193
|
+
}
|
|
22194
|
+
const patchIsRenameAware = /^rename from /m.test(patch.patch_content);
|
|
22195
|
+
const hasGeneratorRename = Object.keys(resolvedFiles).length > 0 && !patchIsRenameAware;
|
|
22196
|
+
if (!needsAccumulation && !hasGeneratorRename) {
|
|
21921
22197
|
const snapshots = /* @__PURE__ */ new Map();
|
|
21922
|
-
const resolvedFiles = {};
|
|
21923
22198
|
for (const filePath of patch.files) {
|
|
21924
|
-
const
|
|
21925
|
-
|
|
21926
|
-
resolvedFiles[filePath] = resolvedPath;
|
|
21927
|
-
}
|
|
21928
|
-
const fullPath = (0, import_path9.join)(this.outputDir, resolvedPath);
|
|
21929
|
-
snapshots.set(resolvedPath, await (0, import_promises4.readFile)(fullPath, "utf-8").catch(() => null));
|
|
22199
|
+
const fullPath = (0, import_path9.join)(this.outputDir, filePath);
|
|
22200
|
+
snapshots.set(filePath, await (0, import_promises4.readFile)(fullPath, "utf-8").catch(() => null));
|
|
21930
22201
|
}
|
|
21931
22202
|
try {
|
|
21932
22203
|
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
@@ -21938,8 +22209,8 @@ var init_dist3 = __esm({
|
|
|
21938
22209
|
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
21939
22210
|
};
|
|
21940
22211
|
} catch {
|
|
21941
|
-
for (const [
|
|
21942
|
-
const fullPath = (0, import_path9.join)(this.outputDir,
|
|
22212
|
+
for (const [filePath, content] of snapshots) {
|
|
22213
|
+
const fullPath = (0, import_path9.join)(this.outputDir, filePath);
|
|
21943
22214
|
if (content != null) {
|
|
21944
22215
|
await (0, import_promises4.writeFile)(fullPath, content);
|
|
21945
22216
|
} else {
|
|
@@ -22059,7 +22330,7 @@ var init_dist3 = __esm({
|
|
|
22059
22330
|
}
|
|
22060
22331
|
let useAccumulatorAsMergeBase = false;
|
|
22061
22332
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
22062
|
-
if (accumulatorEntry && (
|
|
22333
|
+
if (accumulatorEntry && (theirs == null || base == null)) {
|
|
22063
22334
|
theirs = await this.applyPatchToContent(
|
|
22064
22335
|
accumulatorEntry.content,
|
|
22065
22336
|
patch.patch_content,
|
|
@@ -22067,7 +22338,16 @@ var init_dist3 = __esm({
|
|
|
22067
22338
|
tempGit,
|
|
22068
22339
|
tempDir
|
|
22069
22340
|
);
|
|
22070
|
-
if (theirs) {
|
|
22341
|
+
if (theirs != null) {
|
|
22342
|
+
useAccumulatorAsMergeBase = true;
|
|
22343
|
+
} else if (await this.isPatchAlreadyApplied(
|
|
22344
|
+
patch.patch_content,
|
|
22345
|
+
filePath,
|
|
22346
|
+
accumulatorEntry.content,
|
|
22347
|
+
tempGit,
|
|
22348
|
+
tempDir
|
|
22349
|
+
)) {
|
|
22350
|
+
theirs = accumulatorEntry.content;
|
|
22071
22351
|
useAccumulatorAsMergeBase = true;
|
|
22072
22352
|
}
|
|
22073
22353
|
}
|
|
@@ -22084,7 +22364,7 @@ var init_dist3 = __esm({
|
|
|
22084
22364
|
}
|
|
22085
22365
|
let effective_theirs = theirs;
|
|
22086
22366
|
let baseMismatchSkipped = false;
|
|
22087
|
-
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
22367
|
+
if (theirs != null && base != null && !useAccumulatorAsMergeBase) {
|
|
22088
22368
|
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
22089
22369
|
try {
|
|
22090
22370
|
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
@@ -22100,7 +22380,7 @@ var init_dist3 = __esm({
|
|
|
22100
22380
|
baseMismatchSkipped = true;
|
|
22101
22381
|
}
|
|
22102
22382
|
}
|
|
22103
|
-
if (base == null &&
|
|
22383
|
+
if (base == null && ours == null && effective_theirs != null) {
|
|
22104
22384
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
22105
22385
|
content: effective_theirs,
|
|
22106
22386
|
baseGeneration: patch.base_generation
|
|
@@ -22110,7 +22390,7 @@ var init_dist3 = __esm({
|
|
|
22110
22390
|
await (0, import_promises4.writeFile)(oursPath, effective_theirs);
|
|
22111
22391
|
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
22112
22392
|
}
|
|
22113
|
-
if (base == null && ours && effective_theirs && !useAccumulatorAsMergeBase) {
|
|
22393
|
+
if (base == null && ours != null && effective_theirs != null && !useAccumulatorAsMergeBase) {
|
|
22114
22394
|
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
22115
22395
|
const outDir2 = (0, import_path9.dirname)(oursPath);
|
|
22116
22396
|
await (0, import_promises4.mkdir)(outDir2, { recursive: true });
|
|
@@ -22124,16 +22404,20 @@ var init_dist3 = __esm({
|
|
|
22124
22404
|
conflictMetadata: metadata
|
|
22125
22405
|
};
|
|
22126
22406
|
}
|
|
22407
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
22408
|
+
content: merged2.content,
|
|
22409
|
+
baseGeneration: patch.base_generation
|
|
22410
|
+
});
|
|
22127
22411
|
return { file: resolvedPath, status: "merged" };
|
|
22128
22412
|
}
|
|
22129
|
-
if (
|
|
22413
|
+
if (effective_theirs == null) {
|
|
22130
22414
|
return {
|
|
22131
22415
|
file: resolvedPath,
|
|
22132
22416
|
status: "skipped",
|
|
22133
22417
|
reason: "missing-content"
|
|
22134
22418
|
};
|
|
22135
22419
|
}
|
|
22136
|
-
if (base == null && !useAccumulatorAsMergeBase ||
|
|
22420
|
+
if (base == null && !useAccumulatorAsMergeBase || ours == null) {
|
|
22137
22421
|
return {
|
|
22138
22422
|
file: resolvedPath,
|
|
22139
22423
|
status: "skipped",
|
|
@@ -22152,7 +22436,7 @@ var init_dist3 = __esm({
|
|
|
22152
22436
|
const outDir = (0, import_path9.dirname)(oursPath);
|
|
22153
22437
|
await (0, import_promises4.mkdir)(outDir, { recursive: true });
|
|
22154
22438
|
await (0, import_promises4.writeFile)(oursPath, merged.content);
|
|
22155
|
-
if (effective_theirs && !merged.hasConflicts) {
|
|
22439
|
+
if (effective_theirs != null && !merged.hasConflicts) {
|
|
22156
22440
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
22157
22441
|
content: effective_theirs,
|
|
22158
22442
|
baseGeneration: patch.base_generation
|
|
@@ -22250,6 +22534,28 @@ var init_dist3 = __esm({
|
|
|
22250
22534
|
return null;
|
|
22251
22535
|
}
|
|
22252
22536
|
}
|
|
22537
|
+
/**
|
|
22538
|
+
* Detects whether a patch's additions are already present in `content`.
|
|
22539
|
+
* Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
|
|
22540
|
+
* the forward patch is effectively a no-op (its +lines are already there and
|
|
22541
|
+
* its -lines are already gone). Used to distinguish "patch already applied"
|
|
22542
|
+
* from "patch base mismatch" after a forward-apply fails.
|
|
22543
|
+
*/
|
|
22544
|
+
async isPatchAlreadyApplied(patchContent, filePath, content, tempGit, tempDir) {
|
|
22545
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
22546
|
+
if (!fileDiff) return false;
|
|
22547
|
+
try {
|
|
22548
|
+
const tempFilePath = (0, import_path9.join)(tempDir, filePath);
|
|
22549
|
+
await (0, import_promises4.mkdir)((0, import_path9.dirname)(tempFilePath), { recursive: true });
|
|
22550
|
+
await (0, import_promises4.writeFile)(tempFilePath, content);
|
|
22551
|
+
await tempGit.exec(["add", filePath]);
|
|
22552
|
+
await tempGit.exec(["commit", "-m", `check already-applied ${filePath}`, "--allow-empty"]);
|
|
22553
|
+
await tempGit.execWithInput(["apply", "--reverse", "--check", "--allow-empty"], fileDiff);
|
|
22554
|
+
return true;
|
|
22555
|
+
} catch {
|
|
22556
|
+
return false;
|
|
22557
|
+
}
|
|
22558
|
+
}
|
|
22253
22559
|
extractFileDiff(patchContent, filePath) {
|
|
22254
22560
|
const lines = patchContent.split("\n");
|
|
22255
22561
|
const diffLines = [];
|
|
@@ -22403,6 +22709,33 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22403
22709
|
committer;
|
|
22404
22710
|
lockManager;
|
|
22405
22711
|
outputDir;
|
|
22712
|
+
/** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
|
|
22713
|
+
_lastApplyResults = [];
|
|
22714
|
+
/**
|
|
22715
|
+
* Service-level warnings accumulated during a single runReplay() call.
|
|
22716
|
+
* Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
|
|
22717
|
+
* Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
|
|
22718
|
+
* (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
|
|
22719
|
+
* heuristic. Cleared at the top of `runReplay()`.
|
|
22720
|
+
*/
|
|
22721
|
+
warnings = [];
|
|
22722
|
+
/**
|
|
22723
|
+
* @internal Test-only accessor.
|
|
22724
|
+
* Returns the ReplayApplicator instance used for the last run. Tests use this
|
|
22725
|
+
* to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
|
|
22726
|
+
*/
|
|
22727
|
+
get applicatorRef() {
|
|
22728
|
+
return this.applicator;
|
|
22729
|
+
}
|
|
22730
|
+
/**
|
|
22731
|
+
* @internal Test-only accessor.
|
|
22732
|
+
* Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
|
|
22733
|
+
* this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
|
|
22734
|
+
* (FER-9791). Returns an empty array if no patches have been applied yet.
|
|
22735
|
+
*/
|
|
22736
|
+
getLastResults() {
|
|
22737
|
+
return this._lastApplyResults;
|
|
22738
|
+
}
|
|
22406
22739
|
constructor(outputDir, _config) {
|
|
22407
22740
|
const git = new GitClient(outputDir);
|
|
22408
22741
|
this.git = git;
|
|
@@ -22413,6 +22746,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22413
22746
|
this.committer = new ReplayCommitter(git, outputDir);
|
|
22414
22747
|
}
|
|
22415
22748
|
async runReplay(options) {
|
|
22749
|
+
this.warnings = [];
|
|
22416
22750
|
if (options?.skipApplication) {
|
|
22417
22751
|
return this.handleSkipApplication(options);
|
|
22418
22752
|
}
|
|
@@ -22486,6 +22820,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22486
22820
|
);
|
|
22487
22821
|
}
|
|
22488
22822
|
this.lockManager.save();
|
|
22823
|
+
this.detector.warnings.push(...this.lockManager.warnings);
|
|
22489
22824
|
}
|
|
22490
22825
|
determineFlow() {
|
|
22491
22826
|
try {
|
|
@@ -22552,6 +22887,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22552
22887
|
}
|
|
22553
22888
|
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
22554
22889
|
this.lockManager.save();
|
|
22890
|
+
const credentialWarnings = [...this.lockManager.warnings];
|
|
22555
22891
|
if (!options?.stageOnly) {
|
|
22556
22892
|
await this.committer.commitReplay(0);
|
|
22557
22893
|
} else {
|
|
@@ -22563,13 +22899,15 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22563
22899
|
patchesApplied: 0,
|
|
22564
22900
|
patchesWithConflicts: 0,
|
|
22565
22901
|
patchesSkipped: 0,
|
|
22566
|
-
conflicts: []
|
|
22902
|
+
conflicts: [],
|
|
22903
|
+
warnings: credentialWarnings.length > 0 ? credentialWarnings : void 0
|
|
22567
22904
|
};
|
|
22568
22905
|
}
|
|
22569
22906
|
async handleNoPatchesRegeneration(options) {
|
|
22570
|
-
|
|
22571
|
-
const
|
|
22907
|
+
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
22908
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
22572
22909
|
if (options?.dryRun) {
|
|
22910
|
+
const dryRunWarnings = [...detectorWarnings, ...this.warnings];
|
|
22573
22911
|
return {
|
|
22574
22912
|
flow: "no-patches",
|
|
22575
22913
|
patchesDetected: newPatches.length,
|
|
@@ -22579,9 +22917,26 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22579
22917
|
patchesReverted: revertedPatchIds.length,
|
|
22580
22918
|
conflicts: [],
|
|
22581
22919
|
wouldApply: newPatches,
|
|
22582
|
-
warnings:
|
|
22920
|
+
warnings: dryRunWarnings.length > 0 ? dryRunWarnings : void 0
|
|
22583
22921
|
};
|
|
22584
22922
|
}
|
|
22923
|
+
{
|
|
22924
|
+
const preLock = this.lockManager.read();
|
|
22925
|
+
const preCurrentGen = preLock.current_generation;
|
|
22926
|
+
const uniqueFiles = [...new Set(newPatches.flatMap((p) => p.files))];
|
|
22927
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
22928
|
+
for (const file of uniqueFiles) {
|
|
22929
|
+
const diff = await this.git.exec(["diff", preCurrentGen, "HEAD", "--", file]).catch(() => null);
|
|
22930
|
+
if (diff !== null && !diff.trim()) {
|
|
22931
|
+
absorbedFiles.add(file);
|
|
22932
|
+
}
|
|
22933
|
+
}
|
|
22934
|
+
if (absorbedFiles.size > 0) {
|
|
22935
|
+
newPatches = newPatches.filter(
|
|
22936
|
+
(p) => p.files.length === 0 || !p.files.every((f2) => absorbedFiles.has(f2))
|
|
22937
|
+
);
|
|
22938
|
+
}
|
|
22939
|
+
}
|
|
22585
22940
|
for (const id of revertedPatchIds) {
|
|
22586
22941
|
try {
|
|
22587
22942
|
this.lockManager.removePatch(id);
|
|
@@ -22600,6 +22955,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22600
22955
|
let results = [];
|
|
22601
22956
|
if (newPatches.length > 0) {
|
|
22602
22957
|
results = await this.applicator.applyPatches(newPatches);
|
|
22958
|
+
this._lastApplyResults = results;
|
|
22603
22959
|
this.revertConflictingFiles(results);
|
|
22604
22960
|
for (const result of results) {
|
|
22605
22961
|
if (result.status === "conflict") {
|
|
@@ -22614,6 +22970,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22614
22970
|
}
|
|
22615
22971
|
}
|
|
22616
22972
|
this.lockManager.save();
|
|
22973
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
22617
22974
|
if (newPatches.length > 0) {
|
|
22618
22975
|
if (!options?.stageOnly) {
|
|
22619
22976
|
const appliedCount = results.filter((r2) => r2.status === "applied").length;
|
|
@@ -22622,13 +22979,14 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22622
22979
|
await this.committer.stageAll();
|
|
22623
22980
|
}
|
|
22624
22981
|
}
|
|
22982
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
22625
22983
|
return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts, void 0, revertedPatchIds.length);
|
|
22626
22984
|
}
|
|
22627
22985
|
async handleNormalRegeneration(options) {
|
|
22628
22986
|
if (options?.dryRun) {
|
|
22629
22987
|
const existingPatches2 = this.lockManager.getPatches();
|
|
22630
22988
|
const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.detector.detectNewPatches();
|
|
22631
|
-
const warnings2 = [...this.detector.warnings];
|
|
22989
|
+
const warnings2 = [...this.detector.warnings, ...this.warnings];
|
|
22632
22990
|
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
22633
22991
|
return {
|
|
22634
22992
|
flow: "normal-regeneration",
|
|
@@ -22660,7 +23018,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22660
23018
|
}
|
|
22661
23019
|
existingPatches = this.lockManager.getPatches();
|
|
22662
23020
|
let { patches: newPatches, revertedPatchIds } = await this.detector.detectNewPatches();
|
|
22663
|
-
const
|
|
23021
|
+
const detectorWarnings = [...this.detector.warnings];
|
|
22664
23022
|
if (removedByPreRebase.length > 0) {
|
|
22665
23023
|
const removedOriginalCommits = new Set(removedByPreRebase.map((p) => p.original_commit));
|
|
22666
23024
|
const removedOriginalMessages = new Set(removedByPreRebase.map((p) => p.original_message));
|
|
@@ -22692,6 +23050,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22692
23050
|
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
22693
23051
|
this.lockManager.addGeneration(genRecord);
|
|
22694
23052
|
const results = await this.applicator.applyPatches(allPatches);
|
|
23053
|
+
this._lastApplyResults = results;
|
|
22695
23054
|
this.revertConflictingFiles(results);
|
|
22696
23055
|
for (const result of results) {
|
|
22697
23056
|
if (result.status === "conflict") {
|
|
@@ -22713,12 +23072,14 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22713
23072
|
}
|
|
22714
23073
|
}
|
|
22715
23074
|
this.lockManager.save();
|
|
23075
|
+
this.warnings.push(...this.lockManager.warnings);
|
|
22716
23076
|
if (options?.stageOnly) {
|
|
22717
23077
|
await this.committer.stageAll();
|
|
22718
23078
|
} else {
|
|
22719
23079
|
const appliedCount = results.filter((r2) => r2.status === "applied").length;
|
|
22720
23080
|
await this.committer.commitReplay(appliedCount, allPatches);
|
|
22721
23081
|
}
|
|
23082
|
+
const warnings = [...detectorWarnings, ...this.warnings];
|
|
22722
23083
|
return this.buildReport(
|
|
22723
23084
|
"normal-regeneration",
|
|
22724
23085
|
allPatches,
|
|
@@ -22742,6 +23103,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22742
23103
|
let keptAsUserOwned = 0;
|
|
22743
23104
|
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
22744
23105
|
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
23106
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
22745
23107
|
for (const result of results) {
|
|
22746
23108
|
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
22747
23109
|
const patch = result.patch;
|
|
@@ -22762,34 +23124,78 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22762
23124
|
const patch = result.patch;
|
|
22763
23125
|
if (patch.base_generation === currentGenSha) continue;
|
|
22764
23126
|
try {
|
|
22765
|
-
const
|
|
22766
|
-
|
|
23127
|
+
const originalByResolved = {};
|
|
23128
|
+
if (result.resolvedFiles) {
|
|
23129
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
23130
|
+
originalByResolved[resolved] = orig;
|
|
23131
|
+
}
|
|
23132
|
+
}
|
|
23133
|
+
const isUserOwnedPerFile = await Promise.all(
|
|
22767
23134
|
patch.files.map(async (file) => {
|
|
22768
|
-
if (file === ".fernignore")
|
|
23135
|
+
if (file === ".fernignore") return true;
|
|
23136
|
+
if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) {
|
|
22769
23137
|
return true;
|
|
22770
23138
|
}
|
|
22771
|
-
if (
|
|
23139
|
+
if (patch.user_owned) return true;
|
|
23140
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalByResolved[file] ?? file)) {
|
|
22772
23141
|
return true;
|
|
22773
23142
|
}
|
|
22774
23143
|
const content = await this.git.showFile(currentGenSha, file);
|
|
22775
23144
|
return content === null;
|
|
22776
23145
|
})
|
|
22777
23146
|
);
|
|
22778
|
-
const
|
|
22779
|
-
|
|
22780
|
-
|
|
22781
|
-
|
|
22782
|
-
|
|
23147
|
+
const hasProtectedFiles = patch.files.some(
|
|
23148
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
|
|
23149
|
+
);
|
|
23150
|
+
const allUserOwned = isUserOwnedPerFile.every(Boolean);
|
|
23151
|
+
if (allUserOwned && patch.files.length > 0) {
|
|
23152
|
+
const baseChanged = patch.base_generation !== currentGenSha;
|
|
23153
|
+
const userDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
23154
|
+
if (userDiff != null && userDiff.trim()) {
|
|
23155
|
+
const newHash = this.detector.computeContentHash(userDiff);
|
|
23156
|
+
patch.patch_content = userDiff;
|
|
23157
|
+
patch.content_hash = newHash;
|
|
23158
|
+
}
|
|
23159
|
+
patch.base_generation = currentGenSha;
|
|
23160
|
+
try {
|
|
23161
|
+
this.lockManager.updatePatch(patch.id, {
|
|
23162
|
+
base_generation: currentGenSha,
|
|
23163
|
+
...!patch.user_owned ? { user_owned: true } : {},
|
|
23164
|
+
...userDiff != null && userDiff.trim() ? { patch_content: patch.patch_content, content_hash: patch.content_hash } : {}
|
|
23165
|
+
});
|
|
23166
|
+
} catch {
|
|
23167
|
+
}
|
|
23168
|
+
patch.user_owned = true;
|
|
22783
23169
|
keptAsUserOwned++;
|
|
23170
|
+
if (baseChanged) repointed++;
|
|
22784
23171
|
continue;
|
|
22785
23172
|
}
|
|
22786
23173
|
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
22787
|
-
if (
|
|
23174
|
+
if (diff === null) continue;
|
|
23175
|
+
if (!diff.trim()) {
|
|
23176
|
+
if (hasProtectedFiles) {
|
|
23177
|
+
patch.base_generation = currentGenSha;
|
|
23178
|
+
try {
|
|
23179
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
23180
|
+
} catch {
|
|
23181
|
+
}
|
|
23182
|
+
keptAsUserOwned++;
|
|
23183
|
+
continue;
|
|
23184
|
+
}
|
|
22788
23185
|
this.lockManager.removePatch(patch.id);
|
|
22789
23186
|
absorbedPatchIds.add(patch.id);
|
|
22790
23187
|
absorbed++;
|
|
22791
23188
|
continue;
|
|
22792
23189
|
}
|
|
23190
|
+
if (hasProtectedFiles) {
|
|
23191
|
+
patch.base_generation = currentGenSha;
|
|
23192
|
+
try {
|
|
23193
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGenSha });
|
|
23194
|
+
} catch {
|
|
23195
|
+
}
|
|
23196
|
+
keptAsUserOwned++;
|
|
23197
|
+
continue;
|
|
23198
|
+
}
|
|
22793
23199
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
22794
23200
|
if (seenContentHashes.has(newContentHash)) {
|
|
22795
23201
|
this.lockManager.removePatch(patch.id);
|
|
@@ -22798,17 +23204,89 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22798
23204
|
continue;
|
|
22799
23205
|
}
|
|
22800
23206
|
seenContentHashes.add(newContentHash);
|
|
22801
|
-
|
|
22802
|
-
|
|
22803
|
-
|
|
22804
|
-
|
|
22805
|
-
|
|
23207
|
+
patch.base_generation = currentGenSha;
|
|
23208
|
+
patch.patch_content = diff;
|
|
23209
|
+
patch.content_hash = newContentHash;
|
|
23210
|
+
try {
|
|
23211
|
+
this.lockManager.updatePatch(patch.id, {
|
|
23212
|
+
base_generation: currentGenSha,
|
|
23213
|
+
patch_content: diff,
|
|
23214
|
+
content_hash: newContentHash
|
|
23215
|
+
});
|
|
23216
|
+
} catch {
|
|
23217
|
+
}
|
|
22806
23218
|
contentRebased++;
|
|
22807
23219
|
} catch {
|
|
22808
23220
|
}
|
|
22809
23221
|
}
|
|
22810
23222
|
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
22811
23223
|
}
|
|
23224
|
+
/**
|
|
23225
|
+
* Determine whether `file` is user-owned (never produced by the generator).
|
|
23226
|
+
*
|
|
23227
|
+
* Resolution order (first match wins):
|
|
23228
|
+
* 1. `patch.user_owned === true` — authoritative, persisted across cycles.
|
|
23229
|
+
* 2. `.fernignore` itself, or file matches a .fernignore pattern.
|
|
23230
|
+
* 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
|
|
23231
|
+
* creation. Works for patches that predate the `user_owned` field or
|
|
23232
|
+
* whose flag was lost. Immune to rename tricks — relies on raw patch text.
|
|
23233
|
+
* 4. Tree-based check against `patch.base_generation` when it is reachable
|
|
23234
|
+
* AND distinct from `currentGenSha`. If the base tree is unreachable
|
|
23235
|
+
* (shallow clone / aggressive `git gc --prune`), emit a one-time
|
|
23236
|
+
* customer-actionable warning (deduplicated via warnedGens) and
|
|
23237
|
+
* conservatively treat as user-owned — strictly safer than silent
|
|
23238
|
+
* absorption (FER-9809).
|
|
23239
|
+
* 5. Final fallback — check `currentGenSha` when there is no usable base
|
|
23240
|
+
* (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
|
|
23241
|
+
*
|
|
23242
|
+
* `originalFileName` is the pre-rename path when the generator moved the
|
|
23243
|
+
* file between the patch's base and the current generation. Tree lookups
|
|
23244
|
+
* at ancestor generations use the original path — that's where the
|
|
23245
|
+
* generator produced the content.
|
|
23246
|
+
*/
|
|
23247
|
+
async isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens, originalFileName) {
|
|
23248
|
+
if (patch.user_owned) return true;
|
|
23249
|
+
if (file === ".fernignore") return true;
|
|
23250
|
+
if (fernignorePatterns.some((p) => file === p || minimatch(file, p))) return true;
|
|
23251
|
+
if (this.fileIsCreationInPatchContent(patch.patch_content, originalFileName ?? file)) {
|
|
23252
|
+
return true;
|
|
23253
|
+
}
|
|
23254
|
+
const base = patch.base_generation;
|
|
23255
|
+
if (base && base !== currentGenSha) {
|
|
23256
|
+
const reachable = await this.git.commitExists(base) || await this.git.treeExists(base);
|
|
23257
|
+
if (!reachable) {
|
|
23258
|
+
if (!warnedGens.has(base)) {
|
|
23259
|
+
warnedGens.add(base);
|
|
23260
|
+
this.warnings.push(
|
|
23261
|
+
`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.`
|
|
23262
|
+
);
|
|
23263
|
+
}
|
|
23264
|
+
return true;
|
|
23265
|
+
}
|
|
23266
|
+
return await this.git.showFile(base, originalFileName ?? file) === null;
|
|
23267
|
+
}
|
|
23268
|
+
return await this.git.showFile(currentGenSha, originalFileName ?? file) === null;
|
|
23269
|
+
}
|
|
23270
|
+
/**
|
|
23271
|
+
* Returns true if `file`'s diff section in `patchContent` starts with
|
|
23272
|
+
* `--- /dev/null`, meaning this file was introduced by the patch (user
|
|
23273
|
+
* creation). Used as a legacy-safe fallback when `patch.user_owned` is
|
|
23274
|
+
* absent or cleared.
|
|
23275
|
+
*/
|
|
23276
|
+
fileIsCreationInPatchContent(patchContent, file) {
|
|
23277
|
+
const lines = patchContent.split("\n");
|
|
23278
|
+
let inFileSection = false;
|
|
23279
|
+
for (const line of lines) {
|
|
23280
|
+
if (line.startsWith("diff --git ")) {
|
|
23281
|
+
inFileSection = line.includes(` b/${file}`) || line.endsWith(` b/${file}`);
|
|
23282
|
+
continue;
|
|
23283
|
+
}
|
|
23284
|
+
if (inFileSection && line.startsWith("--- ")) {
|
|
23285
|
+
return line === "--- /dev/null";
|
|
23286
|
+
}
|
|
23287
|
+
}
|
|
23288
|
+
return false;
|
|
23289
|
+
}
|
|
22812
23290
|
/**
|
|
22813
23291
|
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
22814
23292
|
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
@@ -22822,13 +23300,14 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22822
23300
|
const cleanFiles = result.fileResults.filter((f2) => f2.status === "merged").map((f2) => f2.file);
|
|
22823
23301
|
if (cleanFiles.length === 0) return;
|
|
22824
23302
|
const patch = result.patch;
|
|
23303
|
+
if (patch.user_owned) return;
|
|
22825
23304
|
const fernignorePatterns = this.readFernignorePatterns();
|
|
23305
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
22826
23306
|
const generatorCleanFiles = [];
|
|
22827
23307
|
for (const file of cleanFiles) {
|
|
22828
|
-
if (file
|
|
22829
|
-
|
|
22830
|
-
|
|
22831
|
-
if (content === null) continue;
|
|
23308
|
+
if (await this.isFileUserOwned(file, patch, currentGenSha, fernignorePatterns, warnedGens)) {
|
|
23309
|
+
continue;
|
|
23310
|
+
}
|
|
22832
23311
|
generatorCleanFiles.push(file);
|
|
22833
23312
|
}
|
|
22834
23313
|
if (generatorCleanFiles.length === 0) return;
|
|
@@ -22865,7 +23344,35 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22865
23344
|
let conflictResolved = 0;
|
|
22866
23345
|
let conflictAbsorbed = 0;
|
|
22867
23346
|
let contentRefreshed = 0;
|
|
23347
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
23348
|
+
const warnedGens = /* @__PURE__ */ new Set();
|
|
23349
|
+
const reachabilityCache = /* @__PURE__ */ new Map();
|
|
23350
|
+
const isReachable = async (sha) => {
|
|
23351
|
+
const cached = reachabilityCache.get(sha);
|
|
23352
|
+
if (cached !== void 0) return cached;
|
|
23353
|
+
const reachable = await this.git.commitExists(sha) || await this.git.treeExists(sha);
|
|
23354
|
+
reachabilityCache.set(sha, reachable);
|
|
23355
|
+
return reachable;
|
|
23356
|
+
};
|
|
23357
|
+
const allPatchFilesUserOwned = async (patch) => {
|
|
23358
|
+
if (patch.user_owned) return true;
|
|
23359
|
+
if (patch.files.length === 0) return false;
|
|
23360
|
+
for (const file of patch.files) {
|
|
23361
|
+
if (!await this.isFileUserOwned(file, patch, currentGen, fernignorePatterns, warnedGens)) {
|
|
23362
|
+
return false;
|
|
23363
|
+
}
|
|
23364
|
+
}
|
|
23365
|
+
return true;
|
|
23366
|
+
};
|
|
22868
23367
|
for (const patch of patches) {
|
|
23368
|
+
if (patch.base_generation && patch.base_generation !== currentGen && !warnedGens.has(patch.base_generation)) {
|
|
23369
|
+
if (!await isReachable(patch.base_generation)) {
|
|
23370
|
+
warnedGens.add(patch.base_generation);
|
|
23371
|
+
this.warnings.push(
|
|
23372
|
+
`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.`
|
|
23373
|
+
);
|
|
23374
|
+
}
|
|
23375
|
+
}
|
|
22869
23376
|
if (patch.status != null) {
|
|
22870
23377
|
delete patch.status;
|
|
22871
23378
|
continue;
|
|
@@ -22875,6 +23382,7 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22875
23382
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
22876
23383
|
if (diff === null) continue;
|
|
22877
23384
|
if (!diff.trim()) {
|
|
23385
|
+
if (await allPatchFilesUserOwned(patch)) continue;
|
|
22878
23386
|
this.lockManager.removePatch(patch.id);
|
|
22879
23387
|
contentRefreshed++;
|
|
22880
23388
|
continue;
|
|
@@ -22886,6 +23394,12 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22886
23394
|
if (hasStaleMarkers) {
|
|
22887
23395
|
continue;
|
|
22888
23396
|
}
|
|
23397
|
+
const isProtected = patch.files.some(
|
|
23398
|
+
(file) => file === ".fernignore" || fernignorePatterns.some((p) => file === p || minimatch(file, p))
|
|
23399
|
+
);
|
|
23400
|
+
if (isProtected) {
|
|
23401
|
+
continue;
|
|
23402
|
+
}
|
|
22889
23403
|
const newContentHash = this.detector.computeContentHash(diff);
|
|
22890
23404
|
if (newContentHash !== patch.content_hash) {
|
|
22891
23405
|
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
@@ -22911,6 +23425,13 @@ CLI Version: ${options.cliVersion}`;
|
|
|
22911
23425
|
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
22912
23426
|
if (diff === null) continue;
|
|
22913
23427
|
if (!diff.trim()) {
|
|
23428
|
+
if (await allPatchFilesUserOwned(patch)) {
|
|
23429
|
+
try {
|
|
23430
|
+
this.lockManager.updatePatch(patch.id, { base_generation: currentGen });
|
|
23431
|
+
} catch {
|
|
23432
|
+
}
|
|
23433
|
+
continue;
|
|
23434
|
+
}
|
|
22914
23435
|
this.lockManager.removePatch(patch.id);
|
|
22915
23436
|
conflictAbsorbed++;
|
|
22916
23437
|
continue;
|
|
@@ -33251,13 +33772,17 @@ var GithubStep = class extends BaseStep {
|
|
|
33251
33772
|
});
|
|
33252
33773
|
switch (branchAction) {
|
|
33253
33774
|
case "replay-branch":
|
|
33254
|
-
|
|
33255
|
-
|
|
33256
|
-
|
|
33257
|
-
|
|
33258
|
-
|
|
33259
|
-
|
|
33260
|
-
|
|
33775
|
+
if (shouldPushGenerationBaseTag(replayResult)) {
|
|
33776
|
+
generationBaseSha = await createReplayBranch(
|
|
33777
|
+
repository,
|
|
33778
|
+
prBranch,
|
|
33779
|
+
this.config.commitMessage,
|
|
33780
|
+
replayConflictInfo,
|
|
33781
|
+
this.logger
|
|
33782
|
+
);
|
|
33783
|
+
} else {
|
|
33784
|
+
await repository.createBranchFromHead(prBranch);
|
|
33785
|
+
}
|
|
33261
33786
|
break;
|
|
33262
33787
|
case "create-from-head":
|
|
33263
33788
|
await repository.createBranchFromHead(prBranch);
|
|
@@ -33466,6 +33991,9 @@ _Fern Run ID: \`${config.runId}\`_`;
|
|
|
33466
33991
|
function shouldEnableAutomerge(config) {
|
|
33467
33992
|
return config.automationMode === true && config.autoMerge === true && config.hasBreakingChanges !== true;
|
|
33468
33993
|
}
|
|
33994
|
+
function shouldPushGenerationBaseTag(replayResult) {
|
|
33995
|
+
return (replayResult?.patchesWithConflicts ?? 0) > 0;
|
|
33996
|
+
}
|
|
33469
33997
|
function resolveBranchAction({
|
|
33470
33998
|
automationMode,
|
|
33471
33999
|
skipCommit,
|
|
@@ -33604,18 +34132,24 @@ async function replayRun(params) {
|
|
|
33604
34132
|
}
|
|
33605
34133
|
}
|
|
33606
34134
|
if (shouldSync && tagSha != null) {
|
|
33607
|
-
|
|
33608
|
-
|
|
33609
|
-
|
|
33610
|
-
|
|
33611
|
-
|
|
33612
|
-
|
|
33613
|
-
|
|
33614
|
-
|
|
33615
|
-
|
|
33616
|
-
|
|
34135
|
+
if (!isTagMergedIntoHead(outputDir, tagSha)) {
|
|
34136
|
+
logger?.warn(
|
|
34137
|
+
`fern-generation-base tag ${tagSha} is not reachable from HEAD \u2014 skipping divergent-merge sync. The tag likely points at an unmerged generation (PR closed without merge).`
|
|
34138
|
+
);
|
|
34139
|
+
} else {
|
|
34140
|
+
const syncService = new ReplayService(outputDir, { enabled: true });
|
|
34141
|
+
await syncService.syncFromDivergentMerge(tagSha, {
|
|
34142
|
+
cliVersion,
|
|
34143
|
+
generatorVersions,
|
|
34144
|
+
baseBranchHead: baseBranchHead ?? void 0
|
|
34145
|
+
});
|
|
34146
|
+
try {
|
|
34147
|
+
const freshLockManager = new LockfileManager(outputDir);
|
|
34148
|
+
if (freshLockManager.exists()) {
|
|
34149
|
+
previousGenerationSha = freshLockManager.read().current_generation;
|
|
34150
|
+
}
|
|
34151
|
+
} catch {
|
|
33617
34152
|
}
|
|
33618
|
-
} catch {
|
|
33619
34153
|
}
|
|
33620
34154
|
}
|
|
33621
34155
|
}
|
|
@@ -33665,6 +34199,30 @@ async function replayRun(params) {
|
|
|
33665
34199
|
baseBranchHead: resolvedBaseBranchHead
|
|
33666
34200
|
};
|
|
33667
34201
|
}
|
|
34202
|
+
function isTagMergedIntoHead(cwd2, tagSha) {
|
|
34203
|
+
try {
|
|
34204
|
+
(0, import_child_process3.execFileSync)("git", ["merge-base", "--is-ancestor", tagSha, "HEAD"], {
|
|
34205
|
+
cwd: cwd2,
|
|
34206
|
+
stdio: "pipe"
|
|
34207
|
+
});
|
|
34208
|
+
return true;
|
|
34209
|
+
} catch {
|
|
34210
|
+
}
|
|
34211
|
+
const tagTree = gitRevParse(cwd2, `${tagSha}^{tree}`);
|
|
34212
|
+
if (tagTree == null) {
|
|
34213
|
+
return false;
|
|
34214
|
+
}
|
|
34215
|
+
try {
|
|
34216
|
+
const trees = (0, import_child_process3.execFileSync)("git", ["log", "HEAD", "--format=%T", "--max-count=500"], {
|
|
34217
|
+
cwd: cwd2,
|
|
34218
|
+
encoding: "utf-8",
|
|
34219
|
+
stdio: "pipe"
|
|
34220
|
+
});
|
|
34221
|
+
return trees.split("\n").some((t2) => t2.trim() === tagTree);
|
|
34222
|
+
} catch {
|
|
34223
|
+
return false;
|
|
34224
|
+
}
|
|
34225
|
+
}
|
|
33668
34226
|
function gitRevParse(cwd2, rev) {
|
|
33669
34227
|
try {
|
|
33670
34228
|
return (0, import_child_process3.execFileSync)("git", ["rev-parse", "--verify", rev], {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fern-api/generator-cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fern-api/fern.git",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"**"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@fern-api/replay": "0.
|
|
25
|
+
"@fern-api/replay": "0.11.0",
|
|
26
26
|
"@octokit/rest": "^22.0.1",
|
|
27
27
|
"es-toolkit": "^1.45.1",
|
|
28
28
|
"tmp-promise": "^3.0.3"
|