@fern-api/replay 0.6.0 → 0.6.2
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 +16642 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/index.cjs +2014 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +463 -0
- package/dist/index.d.ts +463 -12
- package/dist/index.js +1968 -10
- package/dist/index.js.map +1 -1
- package/package.json +16 -6
- package/dist/FernignoreMigrator.d.ts +0 -38
- package/dist/FernignoreMigrator.d.ts.map +0 -1
- package/dist/FernignoreMigrator.js +0 -210
- package/dist/FernignoreMigrator.js.map +0 -1
- package/dist/LockfileManager.d.ts +0 -31
- package/dist/LockfileManager.d.ts.map +0 -1
- package/dist/LockfileManager.js +0 -124
- package/dist/LockfileManager.js.map +0 -1
- package/dist/ReplayApplicator.d.ts +0 -30
- package/dist/ReplayApplicator.d.ts.map +0 -1
- package/dist/ReplayApplicator.js +0 -544
- package/dist/ReplayApplicator.js.map +0 -1
- package/dist/ReplayCommitter.d.ts +0 -19
- package/dist/ReplayCommitter.d.ts.map +0 -1
- package/dist/ReplayCommitter.js +0 -64
- package/dist/ReplayCommitter.js.map +0 -1
- package/dist/ReplayDetector.d.ts +0 -22
- package/dist/ReplayDetector.d.ts.map +0 -1
- package/dist/ReplayDetector.js +0 -147
- package/dist/ReplayDetector.js.map +0 -1
- package/dist/ReplayService.d.ts +0 -100
- package/dist/ReplayService.d.ts.map +0 -1
- package/dist/ReplayService.js +0 -596
- package/dist/ReplayService.js.map +0 -1
- package/dist/ThreeWayMerge.d.ts +0 -11
- package/dist/ThreeWayMerge.d.ts.map +0 -1
- package/dist/ThreeWayMerge.js +0 -48
- package/dist/ThreeWayMerge.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -462
- package/dist/cli.js.map +0 -1
- package/dist/commands/bootstrap.d.ts +0 -46
- package/dist/commands/bootstrap.d.ts.map +0 -1
- package/dist/commands/bootstrap.js +0 -237
- package/dist/commands/bootstrap.js.map +0 -1
- package/dist/commands/forget.d.ts +0 -16
- package/dist/commands/forget.d.ts.map +0 -1
- package/dist/commands/forget.js +0 -27
- package/dist/commands/forget.js.map +0 -1
- package/dist/commands/index.d.ts +0 -6
- package/dist/commands/index.d.ts.map +0 -1
- package/dist/commands/index.js +0 -6
- package/dist/commands/index.js.map +0 -1
- package/dist/commands/reset.d.ts +0 -16
- package/dist/commands/reset.d.ts.map +0 -1
- package/dist/commands/reset.js +0 -25
- package/dist/commands/reset.js.map +0 -1
- package/dist/commands/resolve.d.ts +0 -16
- package/dist/commands/resolve.d.ts.map +0 -1
- package/dist/commands/resolve.js +0 -28
- package/dist/commands/resolve.js.map +0 -1
- package/dist/commands/status.d.ts +0 -26
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -24
- package/dist/commands/status.js.map +0 -1
- package/dist/git/CommitDetection.d.ts +0 -7
- package/dist/git/CommitDetection.d.ts.map +0 -1
- package/dist/git/CommitDetection.js +0 -26
- package/dist/git/CommitDetection.js.map +0 -1
- package/dist/git/GitClient.d.ts +0 -22
- package/dist/git/GitClient.d.ts.map +0 -1
- package/dist/git/GitClient.js +0 -109
- package/dist/git/GitClient.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/types.d.ts +0 -80
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2014 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name in all)
|
|
13
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
24
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
25
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
26
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
27
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
28
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
29
|
+
mod
|
|
30
|
+
));
|
|
31
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
|
+
|
|
33
|
+
// src/git/GitClient.ts
|
|
34
|
+
var GitClient_exports = {};
|
|
35
|
+
__export(GitClient_exports, {
|
|
36
|
+
GitClient: () => GitClient
|
|
37
|
+
});
|
|
38
|
+
var import_simple_git, GitClient;
|
|
39
|
+
var init_GitClient = __esm({
|
|
40
|
+
"src/git/GitClient.ts"() {
|
|
41
|
+
"use strict";
|
|
42
|
+
import_simple_git = require("simple-git");
|
|
43
|
+
GitClient = class {
|
|
44
|
+
git;
|
|
45
|
+
repoPath;
|
|
46
|
+
constructor(repoPath) {
|
|
47
|
+
this.repoPath = repoPath;
|
|
48
|
+
this.git = (0, import_simple_git.simpleGit)(repoPath);
|
|
49
|
+
}
|
|
50
|
+
async exec(args) {
|
|
51
|
+
return this.git.raw(args);
|
|
52
|
+
}
|
|
53
|
+
async execWithInput(args, input) {
|
|
54
|
+
const { spawn } = await import("child_process");
|
|
55
|
+
return new Promise((resolve2, reject) => {
|
|
56
|
+
const proc = spawn("git", args, { cwd: this.repoPath });
|
|
57
|
+
let stdout = "";
|
|
58
|
+
let stderr = "";
|
|
59
|
+
proc.stdout.on("data", (data) => {
|
|
60
|
+
stdout += data.toString();
|
|
61
|
+
});
|
|
62
|
+
proc.stderr.on("data", (data) => {
|
|
63
|
+
stderr += data.toString();
|
|
64
|
+
});
|
|
65
|
+
proc.on("close", (code) => {
|
|
66
|
+
if (code === 0) {
|
|
67
|
+
resolve2(stdout);
|
|
68
|
+
} else {
|
|
69
|
+
reject(new Error(`git ${args.join(" ")} failed (code ${code}): ${stderr}`));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
proc.stdin.write(input);
|
|
73
|
+
proc.stdin.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async formatPatch(commitSha) {
|
|
77
|
+
return this.exec(["format-patch", "-1", commitSha, "--stdout"]);
|
|
78
|
+
}
|
|
79
|
+
async applyPatch(patchContent) {
|
|
80
|
+
await this.execWithInput(["am", "--3way"], patchContent);
|
|
81
|
+
}
|
|
82
|
+
async getTreeHash(commitSha) {
|
|
83
|
+
return (await this.exec(["rev-parse", `${commitSha}^{tree}`])).trim();
|
|
84
|
+
}
|
|
85
|
+
async showFile(treeish, filePath) {
|
|
86
|
+
try {
|
|
87
|
+
return await this.exec(["show", `${treeish}:${filePath}`]);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async getCommitInfo(commitSha) {
|
|
93
|
+
const format = "%H%x00%an%x00%ae%x00%s";
|
|
94
|
+
const output = await this.exec(["log", "-1", `--format=${format}`, commitSha]);
|
|
95
|
+
const [sha, authorName, authorEmail, message] = output.trim().split("\0");
|
|
96
|
+
return { sha, authorName, authorEmail, message };
|
|
97
|
+
}
|
|
98
|
+
async getCommitParents(commitSha) {
|
|
99
|
+
const output = await this.exec(["rev-parse", `${commitSha}^@`]);
|
|
100
|
+
return output.trim().split("\n").filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
async detectRenames(fromTree, toTree) {
|
|
103
|
+
try {
|
|
104
|
+
const output = await this.exec(["diff", "--find-renames", "--name-status", fromTree, toTree]);
|
|
105
|
+
const renames = [];
|
|
106
|
+
for (const line of output.trim().split("\n")) {
|
|
107
|
+
if (!line) continue;
|
|
108
|
+
if (line.startsWith("R")) {
|
|
109
|
+
const parts = line.split(" ");
|
|
110
|
+
if (parts.length >= 3) {
|
|
111
|
+
renames.push({ from: parts[1], to: parts[2] });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return renames;
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async isAncestor(commit, descendant) {
|
|
121
|
+
try {
|
|
122
|
+
const mergeBase = (await this.exec(["merge-base", commit, descendant])).trim();
|
|
123
|
+
return mergeBase === commit;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async commitExists(sha) {
|
|
129
|
+
try {
|
|
130
|
+
const type = await this.exec(["cat-file", "-t", sha]);
|
|
131
|
+
return type.trim() === "commit";
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
getRepoPath() {
|
|
137
|
+
return this.repoPath;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// src/index.ts
|
|
144
|
+
var index_exports = {};
|
|
145
|
+
__export(index_exports, {
|
|
146
|
+
FERN_BOT_EMAIL: () => FERN_BOT_EMAIL,
|
|
147
|
+
FERN_BOT_LOGIN: () => FERN_BOT_LOGIN,
|
|
148
|
+
FERN_BOT_NAME: () => FERN_BOT_NAME,
|
|
149
|
+
FernignoreMigrator: () => FernignoreMigrator,
|
|
150
|
+
GitClient: () => GitClient,
|
|
151
|
+
LockfileManager: () => LockfileManager,
|
|
152
|
+
ReplayApplicator: () => ReplayApplicator,
|
|
153
|
+
ReplayCommitter: () => ReplayCommitter,
|
|
154
|
+
ReplayDetector: () => ReplayDetector,
|
|
155
|
+
ReplayService: () => ReplayService,
|
|
156
|
+
bootstrap: () => bootstrap,
|
|
157
|
+
forget: () => forget,
|
|
158
|
+
isGenerationCommit: () => isGenerationCommit,
|
|
159
|
+
isReplayCommit: () => isReplayCommit,
|
|
160
|
+
reset: () => reset,
|
|
161
|
+
resolve: () => resolve,
|
|
162
|
+
status: () => status,
|
|
163
|
+
threeWayMerge: () => threeWayMerge
|
|
164
|
+
});
|
|
165
|
+
module.exports = __toCommonJS(index_exports);
|
|
166
|
+
init_GitClient();
|
|
167
|
+
|
|
168
|
+
// src/git/CommitDetection.ts
|
|
169
|
+
var FERN_BOT_NAME = "fern-api";
|
|
170
|
+
var FERN_BOT_EMAIL = "115122769+fern-api[bot]@users.noreply.github.com";
|
|
171
|
+
var FERN_BOT_LOGIN = "fern-api[bot]";
|
|
172
|
+
var FERN_SUPPORT_NAMES = ["fern-support", "Fern Support"];
|
|
173
|
+
function isGenerationCommit(commit) {
|
|
174
|
+
const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
|
|
175
|
+
const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
|
|
176
|
+
const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
|
|
177
|
+
// The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
|
|
178
|
+
// GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
|
|
179
|
+
commit.message.startsWith("SDK Generation");
|
|
180
|
+
return isBotAuthor || hasGenerationMarker;
|
|
181
|
+
}
|
|
182
|
+
function isReplayCommit(commit) {
|
|
183
|
+
return commit.message.startsWith("[fern-replay]");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/LockfileManager.ts
|
|
187
|
+
var import_node_fs = require("fs");
|
|
188
|
+
var import_node_path = require("path");
|
|
189
|
+
var import_yaml = require("yaml");
|
|
190
|
+
var LOCKFILE_HEADER = "# DO NOT EDIT MANUALLY - Managed by Fern Replay\n";
|
|
191
|
+
var LockfileManager = class {
|
|
192
|
+
outputDir;
|
|
193
|
+
lock = null;
|
|
194
|
+
constructor(outputDir) {
|
|
195
|
+
this.outputDir = outputDir;
|
|
196
|
+
}
|
|
197
|
+
get lockfilePath() {
|
|
198
|
+
return (0, import_node_path.join)(this.outputDir, ".fern", "replay.lock");
|
|
199
|
+
}
|
|
200
|
+
get customizationsPath() {
|
|
201
|
+
return (0, import_node_path.join)(this.outputDir, ".fern", "replay.yml");
|
|
202
|
+
}
|
|
203
|
+
exists() {
|
|
204
|
+
return (0, import_node_fs.existsSync)(this.lockfilePath);
|
|
205
|
+
}
|
|
206
|
+
read() {
|
|
207
|
+
if (this.lock) {
|
|
208
|
+
return this.lock;
|
|
209
|
+
}
|
|
210
|
+
if (!this.exists()) {
|
|
211
|
+
throw new Error(`Lockfile not found: ${this.lockfilePath}`);
|
|
212
|
+
}
|
|
213
|
+
const content = (0, import_node_fs.readFileSync)(this.lockfilePath, "utf-8");
|
|
214
|
+
this.lock = (0, import_yaml.parse)(content);
|
|
215
|
+
return this.lock;
|
|
216
|
+
}
|
|
217
|
+
initialize(firstGeneration) {
|
|
218
|
+
this.initializeInMemory(firstGeneration);
|
|
219
|
+
this.save();
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Set up in-memory lock state without writing to disk.
|
|
223
|
+
* Useful for bootstrap dry-run where we need state for detection
|
|
224
|
+
* but don't want to persist anything.
|
|
225
|
+
*/
|
|
226
|
+
initializeInMemory(firstGeneration) {
|
|
227
|
+
this.lock = {
|
|
228
|
+
version: "1.0",
|
|
229
|
+
generations: [firstGeneration],
|
|
230
|
+
current_generation: firstGeneration.commit_sha,
|
|
231
|
+
patches: []
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
save() {
|
|
235
|
+
if (!this.lock) {
|
|
236
|
+
throw new Error("No lockfile data to save. Call read() or initialize() first.");
|
|
237
|
+
}
|
|
238
|
+
const dir = (0, import_node_path.dirname)(this.lockfilePath);
|
|
239
|
+
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
240
|
+
(0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
const yaml = (0, import_yaml.stringify)(this.lock, {
|
|
243
|
+
lineWidth: 0,
|
|
244
|
+
blockQuote: "literal"
|
|
245
|
+
});
|
|
246
|
+
const content = LOCKFILE_HEADER + yaml;
|
|
247
|
+
const tmpPath = this.lockfilePath + ".tmp";
|
|
248
|
+
(0, import_node_fs.writeFileSync)(tmpPath, content, "utf-8");
|
|
249
|
+
(0, import_node_fs.renameSync)(tmpPath, this.lockfilePath);
|
|
250
|
+
}
|
|
251
|
+
addGeneration(record) {
|
|
252
|
+
this.ensureLoaded();
|
|
253
|
+
this.lock.generations.push(record);
|
|
254
|
+
this.lock.current_generation = record.commit_sha;
|
|
255
|
+
}
|
|
256
|
+
addPatch(patch) {
|
|
257
|
+
this.ensureLoaded();
|
|
258
|
+
this.lock.patches.push(patch);
|
|
259
|
+
}
|
|
260
|
+
updatePatch(patchId, updates) {
|
|
261
|
+
this.ensureLoaded();
|
|
262
|
+
const patch = this.lock.patches.find((p) => p.id === patchId);
|
|
263
|
+
if (!patch) {
|
|
264
|
+
throw new Error(`Patch not found: ${patchId}`);
|
|
265
|
+
}
|
|
266
|
+
Object.assign(patch, updates);
|
|
267
|
+
}
|
|
268
|
+
removePatch(patchId) {
|
|
269
|
+
this.ensureLoaded();
|
|
270
|
+
this.lock.patches = this.lock.patches.filter((p) => p.id !== patchId);
|
|
271
|
+
}
|
|
272
|
+
clearPatches() {
|
|
273
|
+
this.ensureLoaded();
|
|
274
|
+
this.lock.patches = [];
|
|
275
|
+
}
|
|
276
|
+
getPatches() {
|
|
277
|
+
this.ensureLoaded();
|
|
278
|
+
return this.lock.patches;
|
|
279
|
+
}
|
|
280
|
+
setReplaySkippedAt(timestamp) {
|
|
281
|
+
this.ensureLoaded();
|
|
282
|
+
this.lock.replay_skipped_at = timestamp;
|
|
283
|
+
}
|
|
284
|
+
clearReplaySkippedAt() {
|
|
285
|
+
this.ensureLoaded();
|
|
286
|
+
delete this.lock.replay_skipped_at;
|
|
287
|
+
}
|
|
288
|
+
isReplaySkipped() {
|
|
289
|
+
this.ensureLoaded();
|
|
290
|
+
return this.lock.replay_skipped_at != null;
|
|
291
|
+
}
|
|
292
|
+
getGeneration(commitSha) {
|
|
293
|
+
this.ensureLoaded();
|
|
294
|
+
return this.lock.generations.find((g) => g.commit_sha === commitSha);
|
|
295
|
+
}
|
|
296
|
+
getCustomizationsConfig() {
|
|
297
|
+
if (!(0, import_node_fs.existsSync)(this.customizationsPath)) {
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
const content = (0, import_node_fs.readFileSync)(this.customizationsPath, "utf-8");
|
|
301
|
+
return (0, import_yaml.parse)(content) ?? {};
|
|
302
|
+
}
|
|
303
|
+
ensureLoaded() {
|
|
304
|
+
if (!this.lock) {
|
|
305
|
+
throw new Error("No lockfile loaded. Call read() or initialize() first.");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// src/ReplayDetector.ts
|
|
311
|
+
var import_node_crypto = require("crypto");
|
|
312
|
+
var INFRASTRUCTURE_FILES = /* @__PURE__ */ new Set([".fernignore"]);
|
|
313
|
+
var ReplayDetector = class {
|
|
314
|
+
git;
|
|
315
|
+
lockManager;
|
|
316
|
+
sdkOutputDir;
|
|
317
|
+
warnings = [];
|
|
318
|
+
constructor(git, lockManager, sdkOutputDir) {
|
|
319
|
+
this.git = git;
|
|
320
|
+
this.lockManager = lockManager;
|
|
321
|
+
this.sdkOutputDir = sdkOutputDir;
|
|
322
|
+
}
|
|
323
|
+
async detectNewPatches() {
|
|
324
|
+
const lock = this.lockManager.read();
|
|
325
|
+
const lastGen = this.getLastGeneration(lock);
|
|
326
|
+
if (!lastGen) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
330
|
+
if (!exists) {
|
|
331
|
+
this.warnings.push(
|
|
332
|
+
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Skipping new patch detection. Existing lockfile patches will still be applied.`
|
|
333
|
+
);
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
337
|
+
if (!isAncestor) {
|
|
338
|
+
return this.detectPatchesViaTreeDiff(lastGen);
|
|
339
|
+
}
|
|
340
|
+
const log = await this.git.exec([
|
|
341
|
+
"log",
|
|
342
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
343
|
+
`${lastGen.commit_sha}..HEAD`,
|
|
344
|
+
"--",
|
|
345
|
+
this.sdkOutputDir
|
|
346
|
+
]);
|
|
347
|
+
if (!log.trim()) {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
const commits = this.parseGitLog(log);
|
|
351
|
+
const newPatches = [];
|
|
352
|
+
for (const commit of commits) {
|
|
353
|
+
if (isGenerationCommit(commit)) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const parents = await this.git.getCommitParents(commit.sha);
|
|
357
|
+
if (parents.length > 1) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (lock.patches.find((p) => p.original_commit === commit.sha)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const patchContent = await this.git.formatPatch(commit.sha);
|
|
364
|
+
const contentHash = this.computeContentHash(patchContent);
|
|
365
|
+
if (lock.patches.find((p) => p.content_hash === contentHash)) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
369
|
+
const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f));
|
|
370
|
+
if (files.length === 0) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
newPatches.push({
|
|
374
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
375
|
+
content_hash: contentHash,
|
|
376
|
+
original_commit: commit.sha,
|
|
377
|
+
original_message: commit.message,
|
|
378
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
379
|
+
base_generation: lastGen.commit_sha,
|
|
380
|
+
files,
|
|
381
|
+
patch_content: patchContent
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return newPatches.reverse();
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Compute content hash for deduplication.
|
|
388
|
+
* Removes commit SHA line and index lines before hashing,
|
|
389
|
+
* so rebased commits with same content produce the same hash.
|
|
390
|
+
*/
|
|
391
|
+
computeContentHash(patchContent) {
|
|
392
|
+
const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
|
|
393
|
+
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
394
|
+
}
|
|
395
|
+
/** Detect patches via tree diff for non-linear history. Returns a composite patch. */
|
|
396
|
+
async detectPatchesViaTreeDiff(lastGen) {
|
|
397
|
+
const filesOutput = await this.git.exec(["diff", "--name-only", lastGen.commit_sha, "HEAD"]);
|
|
398
|
+
const files = filesOutput.trim().split("\n").filter(Boolean).filter((f) => !INFRASTRUCTURE_FILES.has(f)).filter((f) => !f.startsWith(".fern/"));
|
|
399
|
+
if (files.length === 0) return [];
|
|
400
|
+
const diff = await this.git.exec(["diff", lastGen.commit_sha, "HEAD", "--", ...files]);
|
|
401
|
+
if (!diff.trim()) return [];
|
|
402
|
+
const contentHash = this.computeContentHash(diff);
|
|
403
|
+
const lock = this.lockManager.read();
|
|
404
|
+
if (lock.patches.some((p) => p.content_hash === contentHash)) {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
408
|
+
return [
|
|
409
|
+
{
|
|
410
|
+
id: `patch-composite-${headSha.slice(0, 8)}`,
|
|
411
|
+
content_hash: contentHash,
|
|
412
|
+
original_commit: headSha,
|
|
413
|
+
original_message: "Customer customizations (composite)",
|
|
414
|
+
original_author: "composite",
|
|
415
|
+
base_generation: lastGen.commit_sha,
|
|
416
|
+
files,
|
|
417
|
+
patch_content: diff
|
|
418
|
+
}
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
parseGitLog(log) {
|
|
422
|
+
return log.trim().split("\n").map((line) => {
|
|
423
|
+
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
424
|
+
return { sha, authorName, authorEmail, message };
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
getLastGeneration(lock) {
|
|
428
|
+
return lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// src/ThreeWayMerge.ts
|
|
433
|
+
var import_node_diff3 = require("node-diff3");
|
|
434
|
+
function threeWayMerge(base, ours, theirs) {
|
|
435
|
+
const baseLines = base.split("\n");
|
|
436
|
+
const oursLines = ours.split("\n");
|
|
437
|
+
const theirsLines = theirs.split("\n");
|
|
438
|
+
const regions = (0, import_node_diff3.diff3Merge)(oursLines, baseLines, theirsLines);
|
|
439
|
+
const outputLines = [];
|
|
440
|
+
const conflicts = [];
|
|
441
|
+
let currentLine = 1;
|
|
442
|
+
for (const region of regions) {
|
|
443
|
+
if (region.ok) {
|
|
444
|
+
outputLines.push(...region.ok);
|
|
445
|
+
currentLine += region.ok.length;
|
|
446
|
+
} else if (region.conflict) {
|
|
447
|
+
const startLine = currentLine;
|
|
448
|
+
outputLines.push("<<<<<<< Generated");
|
|
449
|
+
outputLines.push(...region.conflict.a);
|
|
450
|
+
outputLines.push("=======");
|
|
451
|
+
outputLines.push(...region.conflict.b);
|
|
452
|
+
outputLines.push(">>>>>>> Your customization");
|
|
453
|
+
const conflictLines = region.conflict.a.length + region.conflict.b.length + 3;
|
|
454
|
+
conflicts.push({
|
|
455
|
+
startLine,
|
|
456
|
+
endLine: startLine + conflictLines - 1,
|
|
457
|
+
ours: region.conflict.a,
|
|
458
|
+
theirs: region.conflict.b
|
|
459
|
+
});
|
|
460
|
+
currentLine += conflictLines;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
content: outputLines.join("\n"),
|
|
465
|
+
hasConflicts: conflicts.length > 0,
|
|
466
|
+
conflicts
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/ReplayApplicator.ts
|
|
471
|
+
var import_promises = require("fs/promises");
|
|
472
|
+
var import_node_os = require("os");
|
|
473
|
+
var import_node_path2 = require("path");
|
|
474
|
+
var import_minimatch = require("minimatch");
|
|
475
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
476
|
+
".png",
|
|
477
|
+
".jpg",
|
|
478
|
+
".jpeg",
|
|
479
|
+
".gif",
|
|
480
|
+
".bmp",
|
|
481
|
+
".ico",
|
|
482
|
+
".webp",
|
|
483
|
+
".svg",
|
|
484
|
+
".pdf",
|
|
485
|
+
".doc",
|
|
486
|
+
".docx",
|
|
487
|
+
".xls",
|
|
488
|
+
".xlsx",
|
|
489
|
+
".ppt",
|
|
490
|
+
".pptx",
|
|
491
|
+
".zip",
|
|
492
|
+
".gz",
|
|
493
|
+
".tar",
|
|
494
|
+
".bz2",
|
|
495
|
+
".7z",
|
|
496
|
+
".rar",
|
|
497
|
+
".jar",
|
|
498
|
+
".war",
|
|
499
|
+
".ear",
|
|
500
|
+
".class",
|
|
501
|
+
".exe",
|
|
502
|
+
".dll",
|
|
503
|
+
".so",
|
|
504
|
+
".dylib",
|
|
505
|
+
".o",
|
|
506
|
+
".a",
|
|
507
|
+
".woff",
|
|
508
|
+
".woff2",
|
|
509
|
+
".ttf",
|
|
510
|
+
".eot",
|
|
511
|
+
".otf",
|
|
512
|
+
".mp3",
|
|
513
|
+
".mp4",
|
|
514
|
+
".avi",
|
|
515
|
+
".mov",
|
|
516
|
+
".wav",
|
|
517
|
+
".flac",
|
|
518
|
+
".sqlite",
|
|
519
|
+
".db",
|
|
520
|
+
".pyc",
|
|
521
|
+
".pyo",
|
|
522
|
+
".DS_Store"
|
|
523
|
+
]);
|
|
524
|
+
var ReplayApplicator = class {
|
|
525
|
+
git;
|
|
526
|
+
lockManager;
|
|
527
|
+
outputDir;
|
|
528
|
+
renameCache = /* @__PURE__ */ new Map();
|
|
529
|
+
fileTheirsAccumulator = /* @__PURE__ */ new Map();
|
|
530
|
+
constructor(git, lockManager, outputDir) {
|
|
531
|
+
this.git = git;
|
|
532
|
+
this.lockManager = lockManager;
|
|
533
|
+
this.outputDir = outputDir;
|
|
534
|
+
}
|
|
535
|
+
/** Reset inter-patch accumulator for a new cycle. */
|
|
536
|
+
resetAccumulator() {
|
|
537
|
+
this.fileTheirsAccumulator.clear();
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Apply all patches, returning results for each.
|
|
541
|
+
* Skips patches that match exclude patterns in replay.yml
|
|
542
|
+
*/
|
|
543
|
+
async applyPatches(patches) {
|
|
544
|
+
this.resetAccumulator();
|
|
545
|
+
const results = [];
|
|
546
|
+
for (const patch of patches) {
|
|
547
|
+
if (this.isExcluded(patch)) {
|
|
548
|
+
results.push({
|
|
549
|
+
patch,
|
|
550
|
+
status: "skipped",
|
|
551
|
+
method: "git-am"
|
|
552
|
+
});
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const result = await this.applyPatchWithFallback(patch);
|
|
556
|
+
results.push(result);
|
|
557
|
+
}
|
|
558
|
+
return results;
|
|
559
|
+
}
|
|
560
|
+
/** Populate accumulator after git apply succeeds. */
|
|
561
|
+
async populateAccumulatorForPatch(patch, baseGen, currentTreeHash) {
|
|
562
|
+
if (!baseGen) return;
|
|
563
|
+
const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-acc-"));
|
|
564
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
565
|
+
const tempGit = new GitClient2(tempDir);
|
|
566
|
+
await tempGit.exec(["init"]);
|
|
567
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
568
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
569
|
+
try {
|
|
570
|
+
for (const filePath of patch.files) {
|
|
571
|
+
if (isBinaryFile(filePath)) continue;
|
|
572
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
573
|
+
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
574
|
+
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
575
|
+
if (theirs && base) {
|
|
576
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
577
|
+
content: theirs,
|
|
578
|
+
baseGeneration: patch.base_generation
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} finally {
|
|
583
|
+
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async applyPatchWithFallback(patch) {
|
|
588
|
+
const baseGen = this.lockManager.getGeneration(patch.base_generation);
|
|
589
|
+
const lock = this.lockManager.read();
|
|
590
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
591
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen?.tree_hash ?? "";
|
|
592
|
+
const needsAccumulation = await Promise.all(
|
|
593
|
+
patch.files.map(async (f) => {
|
|
594
|
+
if (!baseGen) return false;
|
|
595
|
+
const resolved = await this.resolveFilePath(f, baseGen.tree_hash, currentTreeHash);
|
|
596
|
+
return this.fileTheirsAccumulator.has(resolved);
|
|
597
|
+
})
|
|
598
|
+
).then((results) => results.some(Boolean));
|
|
599
|
+
if (!needsAccumulation) {
|
|
600
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
601
|
+
const resolvedFiles = {};
|
|
602
|
+
for (const filePath of patch.files) {
|
|
603
|
+
const resolvedPath = baseGen ? await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash) : filePath;
|
|
604
|
+
if (resolvedPath !== filePath) {
|
|
605
|
+
resolvedFiles[filePath] = resolvedPath;
|
|
606
|
+
}
|
|
607
|
+
const fullPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
|
|
608
|
+
snapshots.set(resolvedPath, await (0, import_promises.readFile)(fullPath, "utf-8").catch(() => null));
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
await this.git.execWithInput(["apply", "--3way"], patch.patch_content);
|
|
612
|
+
await this.populateAccumulatorForPatch(patch, baseGen, currentTreeHash);
|
|
613
|
+
return {
|
|
614
|
+
patch,
|
|
615
|
+
status: "applied",
|
|
616
|
+
method: "git-am",
|
|
617
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
618
|
+
};
|
|
619
|
+
} catch {
|
|
620
|
+
for (const [resolvedPath, content] of snapshots) {
|
|
621
|
+
if (content != null) {
|
|
622
|
+
await (0, import_promises.writeFile)((0, import_node_path2.join)(this.outputDir, resolvedPath), content);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return this.applyWithThreeWayMerge(patch);
|
|
628
|
+
}
|
|
629
|
+
async applyWithThreeWayMerge(patch) {
|
|
630
|
+
const fileResults = [];
|
|
631
|
+
const resolvedFiles = {};
|
|
632
|
+
const tempDir = await (0, import_promises.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "replay-"));
|
|
633
|
+
const { GitClient: GitClient2 } = await Promise.resolve().then(() => (init_GitClient(), GitClient_exports));
|
|
634
|
+
const tempGit = new GitClient2(tempDir);
|
|
635
|
+
await tempGit.exec(["init"]);
|
|
636
|
+
await tempGit.exec(["config", "user.email", "replay@fern.com"]);
|
|
637
|
+
await tempGit.exec(["config", "user.name", "Fern Replay"]);
|
|
638
|
+
try {
|
|
639
|
+
for (const filePath of patch.files) {
|
|
640
|
+
if (isBinaryFile(filePath)) {
|
|
641
|
+
fileResults.push({
|
|
642
|
+
file: filePath,
|
|
643
|
+
status: "skipped",
|
|
644
|
+
reason: "binary-file"
|
|
645
|
+
});
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const result = await this.mergeFile(patch, filePath, tempGit, tempDir);
|
|
649
|
+
if (result.file !== filePath) {
|
|
650
|
+
resolvedFiles[filePath] = result.file;
|
|
651
|
+
}
|
|
652
|
+
fileResults.push(result);
|
|
653
|
+
}
|
|
654
|
+
} finally {
|
|
655
|
+
await (0, import_promises.rm)(tempDir, { recursive: true }).catch(() => {
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
const conflictFiles = fileResults.filter((r) => r.status === "conflict");
|
|
659
|
+
const hasConflicts = conflictFiles.length > 0;
|
|
660
|
+
const conflictReason = hasConflicts ? conflictFiles.some((f) => f.conflictReason === "base-generation-mismatch") ? "base-generation-mismatch" : conflictFiles[0]?.conflictReason : void 0;
|
|
661
|
+
return {
|
|
662
|
+
patch,
|
|
663
|
+
status: hasConflicts ? "conflict" : "applied",
|
|
664
|
+
method: "3way-merge",
|
|
665
|
+
fileResults,
|
|
666
|
+
conflictReason,
|
|
667
|
+
...Object.keys(resolvedFiles).length > 0 && { resolvedFiles }
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
async mergeFile(patch, filePath, tempGit, tempDir) {
|
|
671
|
+
try {
|
|
672
|
+
const baseGen = this.lockManager.getGeneration(patch.base_generation);
|
|
673
|
+
if (!baseGen) {
|
|
674
|
+
return { file: filePath, status: "skipped", reason: "base-generation-not-found" };
|
|
675
|
+
}
|
|
676
|
+
const lock = this.lockManager.read();
|
|
677
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
678
|
+
const currentTreeHash = currentGen?.tree_hash ?? baseGen.tree_hash;
|
|
679
|
+
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
680
|
+
const metadata = {
|
|
681
|
+
patchId: patch.id,
|
|
682
|
+
patchMessage: patch.original_message,
|
|
683
|
+
baseGeneration: patch.base_generation,
|
|
684
|
+
currentGeneration: lock.current_generation
|
|
685
|
+
};
|
|
686
|
+
let base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
687
|
+
let renameSourcePath;
|
|
688
|
+
if (!base) {
|
|
689
|
+
const renameSource = this.extractRenameSource(patch.patch_content, filePath);
|
|
690
|
+
if (renameSource) {
|
|
691
|
+
base = await this.git.showFile(baseGen.tree_hash, renameSource);
|
|
692
|
+
renameSourcePath = renameSource;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const oursPath = (0, import_node_path2.join)(this.outputDir, resolvedPath);
|
|
696
|
+
const ours = await (0, import_promises.readFile)(oursPath, "utf-8").catch(() => null);
|
|
697
|
+
let theirs = await this.applyPatchToContent(
|
|
698
|
+
base,
|
|
699
|
+
patch.patch_content,
|
|
700
|
+
filePath,
|
|
701
|
+
tempGit,
|
|
702
|
+
tempDir,
|
|
703
|
+
renameSourcePath
|
|
704
|
+
);
|
|
705
|
+
let useAccumulatorAsMergeBase = false;
|
|
706
|
+
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
707
|
+
if (!theirs && base && accumulatorEntry) {
|
|
708
|
+
theirs = await this.applyPatchToContent(
|
|
709
|
+
accumulatorEntry.content,
|
|
710
|
+
patch.patch_content,
|
|
711
|
+
filePath,
|
|
712
|
+
tempGit,
|
|
713
|
+
tempDir
|
|
714
|
+
);
|
|
715
|
+
if (theirs) {
|
|
716
|
+
useAccumulatorAsMergeBase = true;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
let effective_theirs = theirs;
|
|
720
|
+
let baseMismatchSkipped = false;
|
|
721
|
+
if (theirs && base && !useAccumulatorAsMergeBase) {
|
|
722
|
+
if (accumulatorEntry && accumulatorEntry.baseGeneration === patch.base_generation) {
|
|
723
|
+
try {
|
|
724
|
+
const preMerged = threeWayMerge(base, accumulatorEntry.content, theirs);
|
|
725
|
+
if (!preMerged.hasConflicts) {
|
|
726
|
+
effective_theirs = preMerged.content;
|
|
727
|
+
} else {
|
|
728
|
+
effective_theirs = theirs;
|
|
729
|
+
}
|
|
730
|
+
} catch {
|
|
731
|
+
effective_theirs = theirs;
|
|
732
|
+
}
|
|
733
|
+
} else if (accumulatorEntry) {
|
|
734
|
+
baseMismatchSkipped = true;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (!base && !ours && effective_theirs) {
|
|
738
|
+
const outDir2 = (0, import_node_path2.dirname)(oursPath);
|
|
739
|
+
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
740
|
+
await (0, import_promises.writeFile)(oursPath, effective_theirs);
|
|
741
|
+
return { file: resolvedPath, status: "merged", reason: "new-file" };
|
|
742
|
+
}
|
|
743
|
+
if (!base && ours && effective_theirs) {
|
|
744
|
+
const merged2 = threeWayMerge("", ours, effective_theirs);
|
|
745
|
+
const outDir2 = (0, import_node_path2.dirname)(oursPath);
|
|
746
|
+
await (0, import_promises.mkdir)(outDir2, { recursive: true });
|
|
747
|
+
await (0, import_promises.writeFile)(oursPath, merged2.content);
|
|
748
|
+
if (merged2.hasConflicts) {
|
|
749
|
+
return {
|
|
750
|
+
file: resolvedPath,
|
|
751
|
+
status: "conflict",
|
|
752
|
+
conflicts: merged2.conflicts,
|
|
753
|
+
conflictReason: "new-file-both",
|
|
754
|
+
conflictMetadata: metadata
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
return { file: resolvedPath, status: "merged" };
|
|
758
|
+
}
|
|
759
|
+
if (!effective_theirs) {
|
|
760
|
+
return {
|
|
761
|
+
file: resolvedPath,
|
|
762
|
+
status: "skipped",
|
|
763
|
+
reason: "missing-content"
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
if (!base || !ours) {
|
|
767
|
+
return {
|
|
768
|
+
file: resolvedPath,
|
|
769
|
+
status: "skipped",
|
|
770
|
+
reason: "missing-content"
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
|
|
774
|
+
const merged = threeWayMerge(mergeBase, ours, effective_theirs);
|
|
775
|
+
const outDir = (0, import_node_path2.dirname)(oursPath);
|
|
776
|
+
await (0, import_promises.mkdir)(outDir, { recursive: true });
|
|
777
|
+
await (0, import_promises.writeFile)(oursPath, merged.content);
|
|
778
|
+
if (effective_theirs && base) {
|
|
779
|
+
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
780
|
+
content: effective_theirs,
|
|
781
|
+
baseGeneration: patch.base_generation
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (merged.hasConflicts) {
|
|
785
|
+
return {
|
|
786
|
+
file: resolvedPath,
|
|
787
|
+
status: "conflict",
|
|
788
|
+
conflicts: merged.conflicts,
|
|
789
|
+
conflictReason: baseMismatchSkipped ? "base-generation-mismatch" : "same-line-edit",
|
|
790
|
+
conflictMetadata: metadata
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
return { file: resolvedPath, status: "merged" };
|
|
794
|
+
} catch (error) {
|
|
795
|
+
return {
|
|
796
|
+
file: filePath,
|
|
797
|
+
status: "skipped",
|
|
798
|
+
reason: `error: ${error instanceof Error ? error.message : String(error)}`
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
isExcluded(patch) {
|
|
803
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
804
|
+
if (!config.exclude) return false;
|
|
805
|
+
return patch.files.some((file) => config.exclude.some((pattern) => (0, import_minimatch.minimatch)(file, pattern)));
|
|
806
|
+
}
|
|
807
|
+
async resolveFilePath(filePath, baseTreeHash, currentTreeHash) {
|
|
808
|
+
const config = this.lockManager.getCustomizationsConfig();
|
|
809
|
+
if (config.moves) {
|
|
810
|
+
for (const move of config.moves) {
|
|
811
|
+
if ((0, import_minimatch.minimatch)(filePath, move.from) || filePath === move.from) {
|
|
812
|
+
if (filePath === move.from) {
|
|
813
|
+
return move.to;
|
|
814
|
+
}
|
|
815
|
+
const fromBase = move.from.replace(/\*\*.*$/, "");
|
|
816
|
+
const toBase = move.to.replace(/\*\*.*$/, "");
|
|
817
|
+
if (filePath.startsWith(fromBase)) {
|
|
818
|
+
return toBase + filePath.slice(fromBase.length);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const cacheKey = `${baseTreeHash}:${currentTreeHash}`;
|
|
824
|
+
let renames = this.renameCache.get(cacheKey);
|
|
825
|
+
if (!renames) {
|
|
826
|
+
renames = await this.git.detectRenames(baseTreeHash, currentTreeHash);
|
|
827
|
+
this.renameCache.set(cacheKey, renames);
|
|
828
|
+
}
|
|
829
|
+
const gitRename = renames.find((r) => r.from === filePath);
|
|
830
|
+
if (gitRename) {
|
|
831
|
+
return gitRename.to;
|
|
832
|
+
}
|
|
833
|
+
return filePath;
|
|
834
|
+
}
|
|
835
|
+
async applyPatchToContent(base, patchContent, filePath, tempGit, tempDir, sourceFilePath) {
|
|
836
|
+
if (!base) {
|
|
837
|
+
return this.extractNewFileFromPatch(patchContent, filePath);
|
|
838
|
+
}
|
|
839
|
+
const fileDiff = this.extractFileDiff(patchContent, filePath);
|
|
840
|
+
if (!fileDiff) return null;
|
|
841
|
+
try {
|
|
842
|
+
if (sourceFilePath) {
|
|
843
|
+
const tempSourcePath = (0, import_node_path2.join)(tempDir, sourceFilePath);
|
|
844
|
+
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempSourcePath), { recursive: true });
|
|
845
|
+
await (0, import_promises.writeFile)(tempSourcePath, base);
|
|
846
|
+
await tempGit.exec(["add", sourceFilePath]);
|
|
847
|
+
await tempGit.exec([
|
|
848
|
+
"commit",
|
|
849
|
+
"-m",
|
|
850
|
+
`base for rename ${sourceFilePath} -> ${filePath}`,
|
|
851
|
+
"--allow-empty"
|
|
852
|
+
]);
|
|
853
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
854
|
+
const tempTargetPath = (0, import_node_path2.join)(tempDir, filePath);
|
|
855
|
+
return await (0, import_promises.readFile)(tempTargetPath, "utf-8");
|
|
856
|
+
}
|
|
857
|
+
const tempFilePath = (0, import_node_path2.join)(tempDir, filePath);
|
|
858
|
+
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(tempFilePath), { recursive: true });
|
|
859
|
+
await (0, import_promises.writeFile)(tempFilePath, base);
|
|
860
|
+
await tempGit.exec(["add", filePath]);
|
|
861
|
+
await tempGit.exec(["commit", "-m", `base for ${filePath}`, "--allow-empty"]);
|
|
862
|
+
await tempGit.execWithInput(["apply", "--allow-empty"], fileDiff);
|
|
863
|
+
return await (0, import_promises.readFile)(tempFilePath, "utf-8");
|
|
864
|
+
} catch {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
extractFileDiff(patchContent, filePath) {
|
|
869
|
+
const lines = patchContent.split("\n");
|
|
870
|
+
const diffLines = [];
|
|
871
|
+
let inTargetFile = false;
|
|
872
|
+
for (const line of lines) {
|
|
873
|
+
if (line.startsWith("diff --git")) {
|
|
874
|
+
if (inTargetFile) {
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
if (isDiffLineForFile(line, filePath)) {
|
|
878
|
+
inTargetFile = true;
|
|
879
|
+
diffLines.push(line);
|
|
880
|
+
}
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (inTargetFile) {
|
|
884
|
+
diffLines.push(line);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return diffLines.length > 0 ? diffLines.join("\n") + "\n" : null;
|
|
888
|
+
}
|
|
889
|
+
extractRenameSource(patchContent, targetFilePath) {
|
|
890
|
+
const lines = patchContent.split("\n");
|
|
891
|
+
let inTargetFile = false;
|
|
892
|
+
for (const line of lines) {
|
|
893
|
+
if (line.startsWith("diff --git")) {
|
|
894
|
+
if (inTargetFile) break;
|
|
895
|
+
inTargetFile = isDiffLineForFile(line, targetFilePath);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (!inTargetFile) continue;
|
|
899
|
+
if (line.startsWith("@@")) break;
|
|
900
|
+
if (line.startsWith("rename from ")) {
|
|
901
|
+
return line.slice("rename from ".length);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
extractNewFileFromPatch(patchContent, filePath) {
|
|
907
|
+
const lines = patchContent.split("\n");
|
|
908
|
+
const addedLines = [];
|
|
909
|
+
let inTargetFile = false;
|
|
910
|
+
let inHunk = false;
|
|
911
|
+
let noTrailingNewline = false;
|
|
912
|
+
for (const line of lines) {
|
|
913
|
+
if (line.startsWith("diff --git")) {
|
|
914
|
+
if (inTargetFile) break;
|
|
915
|
+
inTargetFile = isDiffLineForFile(line, filePath);
|
|
916
|
+
inHunk = false;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
if (!inTargetFile) continue;
|
|
920
|
+
if (line.startsWith("@@")) {
|
|
921
|
+
inHunk = true;
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (!inHunk) continue;
|
|
925
|
+
if (line === "\") {
|
|
926
|
+
noTrailingNewline = true;
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
930
|
+
addedLines.push(line.slice(1));
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (addedLines.length === 0) return null;
|
|
934
|
+
return addedLines.join("\n") + (noTrailingNewline ? "" : "\n");
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
function isBinaryFile(filePath) {
|
|
938
|
+
const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
|
|
939
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
940
|
+
}
|
|
941
|
+
function isDiffLineForFile(diffLine, filePath) {
|
|
942
|
+
const match = diffLine.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
943
|
+
return match !== null && match[1] === filePath;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/ReplayCommitter.ts
|
|
947
|
+
var ReplayCommitter = class {
|
|
948
|
+
git;
|
|
949
|
+
outputDir;
|
|
950
|
+
constructor(git, outputDir) {
|
|
951
|
+
this.git = git;
|
|
952
|
+
this.outputDir = outputDir;
|
|
953
|
+
}
|
|
954
|
+
async commitGeneration(message, options) {
|
|
955
|
+
await this.stageAll();
|
|
956
|
+
if (!await this.hasStagedChanges()) {
|
|
957
|
+
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
958
|
+
}
|
|
959
|
+
let fullMessage = `[fern-generated] ${message}
|
|
960
|
+
|
|
961
|
+
Generated by Fern`;
|
|
962
|
+
if (options?.cliVersion) {
|
|
963
|
+
fullMessage += `
|
|
964
|
+
CLI Version: ${options.cliVersion}`;
|
|
965
|
+
}
|
|
966
|
+
if (options?.generatorVersions && Object.keys(options.generatorVersions).length > 0) {
|
|
967
|
+
fullMessage += "\nGenerators:";
|
|
968
|
+
for (const [name, version] of Object.entries(options.generatorVersions)) {
|
|
969
|
+
fullMessage += `
|
|
970
|
+
- ${name}: ${version}`;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
await this.git.exec(["commit", "-m", fullMessage]);
|
|
974
|
+
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
975
|
+
}
|
|
976
|
+
async commitReplay(_patchCount, patches) {
|
|
977
|
+
await this.stageAll();
|
|
978
|
+
if (!await this.hasStagedChanges()) {
|
|
979
|
+
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
980
|
+
}
|
|
981
|
+
let fullMessage = `[fern-replay] Applied customizations`;
|
|
982
|
+
if (patches && patches.length > 0) {
|
|
983
|
+
fullMessage += "\n\nPatches replayed:";
|
|
984
|
+
for (const patch of patches) {
|
|
985
|
+
fullMessage += `
|
|
986
|
+
- ${patch.id}: ${patch.original_message}`;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
await this.git.exec(["commit", "-m", fullMessage]);
|
|
990
|
+
return (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
991
|
+
}
|
|
992
|
+
async createGenerationRecord(options) {
|
|
993
|
+
const commitSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
994
|
+
const treeHash = await this.getTreeHash(commitSha);
|
|
995
|
+
return {
|
|
996
|
+
commit_sha: commitSha,
|
|
997
|
+
tree_hash: treeHash,
|
|
998
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
999
|
+
cli_version: options?.cliVersion ?? "unknown",
|
|
1000
|
+
generator_versions: options?.generatorVersions ?? {},
|
|
1001
|
+
base_branch_head: options?.baseBranchHead
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
async stageAll() {
|
|
1005
|
+
await this.git.exec(["add", "-A", this.outputDir]);
|
|
1006
|
+
}
|
|
1007
|
+
async hasStagedChanges() {
|
|
1008
|
+
const output = await this.git.exec(["diff", "--cached", "--name-only"]);
|
|
1009
|
+
return output.trim().length > 0;
|
|
1010
|
+
}
|
|
1011
|
+
async getTreeHash(commitSha) {
|
|
1012
|
+
return this.git.getTreeHash(commitSha);
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
// src/ReplayService.ts
|
|
1017
|
+
var import_node_fs2 = require("fs");
|
|
1018
|
+
var import_node_path3 = require("path");
|
|
1019
|
+
var import_minimatch2 = require("minimatch");
|
|
1020
|
+
init_GitClient();
|
|
1021
|
+
var ReplayService = class {
|
|
1022
|
+
git;
|
|
1023
|
+
detector;
|
|
1024
|
+
applicator;
|
|
1025
|
+
committer;
|
|
1026
|
+
lockManager;
|
|
1027
|
+
outputDir;
|
|
1028
|
+
constructor(outputDir, _config) {
|
|
1029
|
+
const git = new GitClient(outputDir);
|
|
1030
|
+
this.git = git;
|
|
1031
|
+
this.outputDir = outputDir;
|
|
1032
|
+
this.lockManager = new LockfileManager(outputDir);
|
|
1033
|
+
this.detector = new ReplayDetector(git, this.lockManager, outputDir);
|
|
1034
|
+
this.applicator = new ReplayApplicator(git, this.lockManager, outputDir);
|
|
1035
|
+
this.committer = new ReplayCommitter(git, outputDir);
|
|
1036
|
+
}
|
|
1037
|
+
async runReplay(options) {
|
|
1038
|
+
if (options?.skipApplication) {
|
|
1039
|
+
return this.handleSkipApplication(options);
|
|
1040
|
+
}
|
|
1041
|
+
const flow = this.determineFlow();
|
|
1042
|
+
switch (flow) {
|
|
1043
|
+
case "first-generation":
|
|
1044
|
+
return this.handleFirstGeneration(options);
|
|
1045
|
+
case "no-patches":
|
|
1046
|
+
return this.handleNoPatchesRegeneration(options);
|
|
1047
|
+
case "normal-regeneration":
|
|
1048
|
+
return this.handleNormalRegeneration(options);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Sync the lockfile after a divergent PR was squash-merged.
|
|
1053
|
+
* Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
|
|
1054
|
+
*
|
|
1055
|
+
* After updating the generation record, re-detects customer patches via
|
|
1056
|
+
* tree diff between the generation tag (pure generation tree) and HEAD
|
|
1057
|
+
* (which includes customer customizations after squash merge). This ensures
|
|
1058
|
+
* patches survive the squash merge → regenerate cycle even when the lockfile
|
|
1059
|
+
* was restored from the base branch during conflict PR creation.
|
|
1060
|
+
*/
|
|
1061
|
+
async syncFromDivergentMerge(generationCommitSha, options) {
|
|
1062
|
+
const treeHash = await this.git.getTreeHash(generationCommitSha);
|
|
1063
|
+
const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
|
|
1064
|
+
const record = {
|
|
1065
|
+
commit_sha: generationCommitSha,
|
|
1066
|
+
tree_hash: treeHash,
|
|
1067
|
+
timestamp,
|
|
1068
|
+
cli_version: options?.cliVersion ?? "unknown",
|
|
1069
|
+
generator_versions: options?.generatorVersions ?? {},
|
|
1070
|
+
base_branch_head: options?.baseBranchHead
|
|
1071
|
+
};
|
|
1072
|
+
if (!this.lockManager.exists()) {
|
|
1073
|
+
this.lockManager.initializeInMemory(record);
|
|
1074
|
+
} else {
|
|
1075
|
+
this.lockManager.read();
|
|
1076
|
+
this.lockManager.addGeneration(record);
|
|
1077
|
+
this.lockManager.clearPatches();
|
|
1078
|
+
}
|
|
1079
|
+
this.lockManager.save();
|
|
1080
|
+
try {
|
|
1081
|
+
const redetectedPatches = await this.detector.detectNewPatches();
|
|
1082
|
+
if (redetectedPatches.length > 0) {
|
|
1083
|
+
for (const patch of redetectedPatches) {
|
|
1084
|
+
this.lockManager.addPatch(patch);
|
|
1085
|
+
}
|
|
1086
|
+
this.lockManager.save();
|
|
1087
|
+
}
|
|
1088
|
+
} catch {
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
determineFlow() {
|
|
1092
|
+
if (!this.lockManager.exists()) {
|
|
1093
|
+
return "first-generation";
|
|
1094
|
+
}
|
|
1095
|
+
const lock = this.lockManager.read();
|
|
1096
|
+
if (lock.patches.length === 0) {
|
|
1097
|
+
return "no-patches";
|
|
1098
|
+
}
|
|
1099
|
+
return "normal-regeneration";
|
|
1100
|
+
}
|
|
1101
|
+
async handleFirstGeneration(options) {
|
|
1102
|
+
if (options?.dryRun) {
|
|
1103
|
+
return {
|
|
1104
|
+
flow: "first-generation",
|
|
1105
|
+
patchesDetected: 0,
|
|
1106
|
+
patchesApplied: 0,
|
|
1107
|
+
patchesWithConflicts: 0,
|
|
1108
|
+
patchesSkipped: 0,
|
|
1109
|
+
conflicts: []
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
const commitOpts = options ? {
|
|
1113
|
+
cliVersion: options.cliVersion ?? "unknown",
|
|
1114
|
+
generatorVersions: options.generatorVersions ?? {},
|
|
1115
|
+
baseBranchHead: options.baseBranchHead
|
|
1116
|
+
} : void 0;
|
|
1117
|
+
await this.committer.commitGeneration("Initial SDK generation", commitOpts);
|
|
1118
|
+
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1119
|
+
this.lockManager.initialize(genRecord);
|
|
1120
|
+
return {
|
|
1121
|
+
flow: "first-generation",
|
|
1122
|
+
patchesDetected: 0,
|
|
1123
|
+
patchesApplied: 0,
|
|
1124
|
+
patchesWithConflicts: 0,
|
|
1125
|
+
patchesSkipped: 0,
|
|
1126
|
+
conflicts: []
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Skip-application mode: commit the generation and update the lockfile
|
|
1131
|
+
* but don't detect or apply patches. Sets a marker so the next normal
|
|
1132
|
+
* run skips revert detection in preGenerationRebase().
|
|
1133
|
+
*/
|
|
1134
|
+
async handleSkipApplication(options) {
|
|
1135
|
+
const commitOpts = options ? {
|
|
1136
|
+
cliVersion: options.cliVersion ?? "unknown",
|
|
1137
|
+
generatorVersions: options.generatorVersions ?? {},
|
|
1138
|
+
baseBranchHead: options.baseBranchHead
|
|
1139
|
+
} : void 0;
|
|
1140
|
+
await this.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
1141
|
+
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1142
|
+
if (this.lockManager.exists()) {
|
|
1143
|
+
this.lockManager.read();
|
|
1144
|
+
this.lockManager.addGeneration(genRecord);
|
|
1145
|
+
} else {
|
|
1146
|
+
this.lockManager.initializeInMemory(genRecord);
|
|
1147
|
+
}
|
|
1148
|
+
this.lockManager.setReplaySkippedAt((/* @__PURE__ */ new Date()).toISOString());
|
|
1149
|
+
this.lockManager.save();
|
|
1150
|
+
if (!options?.stageOnly) {
|
|
1151
|
+
await this.committer.commitReplay(0);
|
|
1152
|
+
} else {
|
|
1153
|
+
await this.committer.stageAll();
|
|
1154
|
+
}
|
|
1155
|
+
return {
|
|
1156
|
+
flow: "skip-application",
|
|
1157
|
+
patchesDetected: 0,
|
|
1158
|
+
patchesApplied: 0,
|
|
1159
|
+
patchesWithConflicts: 0,
|
|
1160
|
+
patchesSkipped: 0,
|
|
1161
|
+
conflicts: []
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
async handleNoPatchesRegeneration(options) {
|
|
1165
|
+
const newPatches = await this.detector.detectNewPatches();
|
|
1166
|
+
const warnings = [...this.detector.warnings];
|
|
1167
|
+
if (options?.dryRun) {
|
|
1168
|
+
return {
|
|
1169
|
+
flow: "no-patches",
|
|
1170
|
+
patchesDetected: newPatches.length,
|
|
1171
|
+
patchesApplied: 0,
|
|
1172
|
+
patchesWithConflicts: 0,
|
|
1173
|
+
patchesSkipped: 0,
|
|
1174
|
+
conflicts: [],
|
|
1175
|
+
wouldApply: newPatches,
|
|
1176
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const commitOpts = options ? {
|
|
1180
|
+
cliVersion: options.cliVersion ?? "unknown",
|
|
1181
|
+
generatorVersions: options.generatorVersions ?? {},
|
|
1182
|
+
baseBranchHead: options.baseBranchHead
|
|
1183
|
+
} : void 0;
|
|
1184
|
+
await this.committer.commitGeneration("Update SDK", commitOpts);
|
|
1185
|
+
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1186
|
+
this.lockManager.addGeneration(genRecord);
|
|
1187
|
+
let results = [];
|
|
1188
|
+
if (newPatches.length > 0) {
|
|
1189
|
+
results = await this.applicator.applyPatches(newPatches);
|
|
1190
|
+
}
|
|
1191
|
+
const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
|
|
1192
|
+
for (const patch of newPatches) {
|
|
1193
|
+
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
1194
|
+
this.lockManager.addPatch(patch);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
this.lockManager.save();
|
|
1198
|
+
if (newPatches.length > 0) {
|
|
1199
|
+
if (!options?.stageOnly) {
|
|
1200
|
+
const appliedCount = results.filter((r) => r.status === "applied" || r.status === "conflict").length;
|
|
1201
|
+
if (appliedCount > 0) {
|
|
1202
|
+
await this.committer.commitReplay(appliedCount, newPatches);
|
|
1203
|
+
}
|
|
1204
|
+
} else {
|
|
1205
|
+
await this.committer.stageAll();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return this.buildReport("no-patches", newPatches, results, options, warnings, rebaseCounts);
|
|
1209
|
+
}
|
|
1210
|
+
async handleNormalRegeneration(options) {
|
|
1211
|
+
if (options?.dryRun) {
|
|
1212
|
+
const existingPatches2 = this.lockManager.getPatches();
|
|
1213
|
+
const newPatches2 = await this.detector.detectNewPatches();
|
|
1214
|
+
const warnings2 = [...this.detector.warnings];
|
|
1215
|
+
const allPatches2 = [...existingPatches2, ...newPatches2];
|
|
1216
|
+
return {
|
|
1217
|
+
flow: "normal-regeneration",
|
|
1218
|
+
patchesDetected: allPatches2.length,
|
|
1219
|
+
patchesApplied: 0,
|
|
1220
|
+
patchesWithConflicts: 0,
|
|
1221
|
+
patchesSkipped: 0,
|
|
1222
|
+
conflicts: [],
|
|
1223
|
+
wouldApply: allPatches2,
|
|
1224
|
+
warnings: warnings2.length > 0 ? warnings2 : void 0
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
let existingPatches = this.lockManager.getPatches();
|
|
1228
|
+
const preRebaseCounts = await this.preGenerationRebase(existingPatches);
|
|
1229
|
+
existingPatches = this.lockManager.getPatches();
|
|
1230
|
+
const seenHashes = /* @__PURE__ */ new Set();
|
|
1231
|
+
for (const p of existingPatches) {
|
|
1232
|
+
if (seenHashes.has(p.content_hash)) {
|
|
1233
|
+
this.lockManager.removePatch(p.id);
|
|
1234
|
+
} else {
|
|
1235
|
+
seenHashes.add(p.content_hash);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
existingPatches = this.lockManager.getPatches();
|
|
1239
|
+
const newPatches = await this.detector.detectNewPatches();
|
|
1240
|
+
const warnings = [...this.detector.warnings];
|
|
1241
|
+
const allPatches = [...existingPatches, ...newPatches];
|
|
1242
|
+
const commitOpts = options ? {
|
|
1243
|
+
cliVersion: options.cliVersion ?? "unknown",
|
|
1244
|
+
generatorVersions: options.generatorVersions ?? {},
|
|
1245
|
+
baseBranchHead: options.baseBranchHead
|
|
1246
|
+
} : void 0;
|
|
1247
|
+
await this.committer.commitGeneration("Update SDK", commitOpts);
|
|
1248
|
+
const genRecord = await this.committer.createGenerationRecord(commitOpts);
|
|
1249
|
+
this.lockManager.addGeneration(genRecord);
|
|
1250
|
+
const results = await this.applicator.applyPatches(allPatches);
|
|
1251
|
+
const rebaseCounts = await this.rebasePatches(results, genRecord.commit_sha);
|
|
1252
|
+
for (const patch of newPatches) {
|
|
1253
|
+
if (!rebaseCounts.absorbedPatchIds.has(patch.id)) {
|
|
1254
|
+
this.lockManager.addPatch(patch);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
this.lockManager.save();
|
|
1258
|
+
if (options?.stageOnly) {
|
|
1259
|
+
await this.committer.stageAll();
|
|
1260
|
+
} else {
|
|
1261
|
+
const appliedCount = results.filter((r) => r.status === "applied" || r.status === "conflict").length;
|
|
1262
|
+
if (appliedCount > 0) {
|
|
1263
|
+
await this.committer.commitReplay(appliedCount, allPatches);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return this.buildReport(
|
|
1267
|
+
"normal-regeneration",
|
|
1268
|
+
allPatches,
|
|
1269
|
+
results,
|
|
1270
|
+
options,
|
|
1271
|
+
warnings,
|
|
1272
|
+
rebaseCounts,
|
|
1273
|
+
preRebaseCounts
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Rebase cleanly applied patches so they are relative to the current generation.
|
|
1278
|
+
* This prevents recurring conflicts on subsequent regenerations.
|
|
1279
|
+
* Returns the number of patches rebased.
|
|
1280
|
+
*/
|
|
1281
|
+
async rebasePatches(results, currentGenSha) {
|
|
1282
|
+
let absorbed = 0;
|
|
1283
|
+
let repointed = 0;
|
|
1284
|
+
let contentRebased = 0;
|
|
1285
|
+
let keptAsUserOwned = 0;
|
|
1286
|
+
const seenContentHashes = /* @__PURE__ */ new Set();
|
|
1287
|
+
const absorbedPatchIds = /* @__PURE__ */ new Set();
|
|
1288
|
+
for (const result of results) {
|
|
1289
|
+
if (result.resolvedFiles && Object.keys(result.resolvedFiles).length > 0) {
|
|
1290
|
+
const patch = result.patch;
|
|
1291
|
+
const updatedFiles = patch.files.map((f) => result.resolvedFiles[f] ?? f);
|
|
1292
|
+
patch.files = updatedFiles;
|
|
1293
|
+
try {
|
|
1294
|
+
this.lockManager.updatePatch(patch.id, { files: updatedFiles });
|
|
1295
|
+
} catch {
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
for (const result of results) {
|
|
1300
|
+
if (result.status === "conflict" && result.fileResults) {
|
|
1301
|
+
await this.trimAbsorbedFiles(result, currentGenSha);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
if (result.status !== "applied") continue;
|
|
1305
|
+
const patch = result.patch;
|
|
1306
|
+
if (patch.base_generation === currentGenSha) continue;
|
|
1307
|
+
try {
|
|
1308
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
1309
|
+
const isUserOwned = await Promise.all(
|
|
1310
|
+
patch.files.map(async (file) => {
|
|
1311
|
+
if (file === ".fernignore") {
|
|
1312
|
+
return true;
|
|
1313
|
+
}
|
|
1314
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) {
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
const content = await this.git.showFile(currentGenSha, file);
|
|
1318
|
+
return content === null;
|
|
1319
|
+
})
|
|
1320
|
+
);
|
|
1321
|
+
const hasUserOwnedFiles = isUserOwned.some(Boolean);
|
|
1322
|
+
if (hasUserOwnedFiles) {
|
|
1323
|
+
this.lockManager.updatePatch(patch.id, {
|
|
1324
|
+
base_generation: currentGenSha
|
|
1325
|
+
});
|
|
1326
|
+
keptAsUserOwned++;
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
|
|
1330
|
+
if (!diff || !diff.trim()) {
|
|
1331
|
+
this.lockManager.removePatch(patch.id);
|
|
1332
|
+
absorbedPatchIds.add(patch.id);
|
|
1333
|
+
absorbed++;
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
const newContentHash = this.detector.computeContentHash(diff);
|
|
1337
|
+
if (seenContentHashes.has(newContentHash)) {
|
|
1338
|
+
this.lockManager.removePatch(patch.id);
|
|
1339
|
+
absorbedPatchIds.add(patch.id);
|
|
1340
|
+
absorbed++;
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
seenContentHashes.add(newContentHash);
|
|
1344
|
+
this.lockManager.updatePatch(patch.id, {
|
|
1345
|
+
base_generation: currentGenSha,
|
|
1346
|
+
patch_content: diff,
|
|
1347
|
+
content_hash: newContentHash
|
|
1348
|
+
});
|
|
1349
|
+
contentRebased++;
|
|
1350
|
+
} catch {
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return { absorbed, repointed, contentRebased, keptAsUserOwned, absorbedPatchIds };
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* For conflict patches with mixed results (some files merged, some conflicted),
|
|
1357
|
+
* check if the cleanly merged files were absorbed by the generator (empty diff).
|
|
1358
|
+
* If so, remove them from patch.files so they don't pollute the pre-generation
|
|
1359
|
+
* rebase conflict marker check (`git grep <<<<<<< -- ...patch.files`).
|
|
1360
|
+
*
|
|
1361
|
+
* Non-absorbed clean files stay in patch.files — removing them would lose
|
|
1362
|
+
* the customization on the next generation.
|
|
1363
|
+
*/
|
|
1364
|
+
async trimAbsorbedFiles(result, currentGenSha) {
|
|
1365
|
+
const cleanFiles = result.fileResults.filter((f) => f.status === "merged").map((f) => f.file);
|
|
1366
|
+
if (cleanFiles.length === 0) return;
|
|
1367
|
+
const patch = result.patch;
|
|
1368
|
+
const fernignorePatterns = this.readFernignorePatterns();
|
|
1369
|
+
const generatorCleanFiles = [];
|
|
1370
|
+
for (const file of cleanFiles) {
|
|
1371
|
+
if (file === ".fernignore") continue;
|
|
1372
|
+
if (fernignorePatterns.some((p) => file === p || (0, import_minimatch2.minimatch)(file, p))) continue;
|
|
1373
|
+
const content = await this.git.showFile(currentGenSha, file);
|
|
1374
|
+
if (content === null) continue;
|
|
1375
|
+
generatorCleanFiles.push(file);
|
|
1376
|
+
}
|
|
1377
|
+
if (generatorCleanFiles.length === 0) return;
|
|
1378
|
+
const absorbedFiles = /* @__PURE__ */ new Set();
|
|
1379
|
+
for (const file of generatorCleanFiles) {
|
|
1380
|
+
try {
|
|
1381
|
+
const diff = await this.git.exec(["diff", currentGenSha, "--", file]).catch(() => null);
|
|
1382
|
+
if (!diff || !diff.trim()) {
|
|
1383
|
+
absorbedFiles.add(file);
|
|
1384
|
+
}
|
|
1385
|
+
} catch {
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (absorbedFiles.size === 0) return;
|
|
1389
|
+
const remainingFiles = patch.files.filter((f) => !absorbedFiles.has(f));
|
|
1390
|
+
if (remainingFiles.length === patch.files.length) return;
|
|
1391
|
+
try {
|
|
1392
|
+
this.lockManager.updatePatch(patch.id, { files: remainingFiles });
|
|
1393
|
+
} catch {
|
|
1394
|
+
patch.files = remainingFiles;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Pre-generation rebase: update patches using the customer's current state.
|
|
1399
|
+
* Called BEFORE commitGeneration() while HEAD has customer code.
|
|
1400
|
+
*/
|
|
1401
|
+
async preGenerationRebase(patches) {
|
|
1402
|
+
const lock = this.lockManager.read();
|
|
1403
|
+
const currentGen = lock.current_generation;
|
|
1404
|
+
if (this.lockManager.isReplaySkipped()) {
|
|
1405
|
+
this.lockManager.clearReplaySkippedAt();
|
|
1406
|
+
return { conflictResolved: 0, conflictAbsorbed: 0, contentRefreshed: 0 };
|
|
1407
|
+
}
|
|
1408
|
+
let conflictResolved = 0;
|
|
1409
|
+
let conflictAbsorbed = 0;
|
|
1410
|
+
let contentRefreshed = 0;
|
|
1411
|
+
for (const patch of patches) {
|
|
1412
|
+
if (patch.base_generation === currentGen) {
|
|
1413
|
+
try {
|
|
1414
|
+
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
1415
|
+
if (diff === null) continue;
|
|
1416
|
+
if (!diff.trim()) {
|
|
1417
|
+
this.lockManager.removePatch(patch.id);
|
|
1418
|
+
contentRefreshed++;
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
const newContentHash = this.detector.computeContentHash(diff);
|
|
1422
|
+
if (newContentHash !== patch.content_hash) {
|
|
1423
|
+
const filesOutput = await this.git.exec(["diff", "--name-only", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
1424
|
+
const newFiles = filesOutput ? filesOutput.trim().split("\n").filter(Boolean).filter((f) => !f.startsWith(".fern/")) : patch.files;
|
|
1425
|
+
if (newFiles.length === 0) {
|
|
1426
|
+
this.lockManager.removePatch(patch.id);
|
|
1427
|
+
} else {
|
|
1428
|
+
this.lockManager.updatePatch(patch.id, {
|
|
1429
|
+
patch_content: diff,
|
|
1430
|
+
content_hash: newContentHash,
|
|
1431
|
+
files: newFiles
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
contentRefreshed++;
|
|
1435
|
+
}
|
|
1436
|
+
} catch {
|
|
1437
|
+
}
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
try {
|
|
1441
|
+
const markerFiles = await this.git.exec(["grep", "-l", "<<<<<<<", "HEAD", "--", ...patch.files]).catch(() => "");
|
|
1442
|
+
if (markerFiles.trim()) continue;
|
|
1443
|
+
const diff = await this.git.exec(["diff", currentGen, "HEAD", "--", ...patch.files]).catch(() => null);
|
|
1444
|
+
if (diff === null) continue;
|
|
1445
|
+
if (!diff.trim()) {
|
|
1446
|
+
this.lockManager.removePatch(patch.id);
|
|
1447
|
+
conflictAbsorbed++;
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
const newContentHash = this.detector.computeContentHash(diff);
|
|
1451
|
+
this.lockManager.updatePatch(patch.id, {
|
|
1452
|
+
base_generation: currentGen,
|
|
1453
|
+
patch_content: diff,
|
|
1454
|
+
content_hash: newContentHash
|
|
1455
|
+
});
|
|
1456
|
+
conflictResolved++;
|
|
1457
|
+
} catch {
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return { conflictResolved, conflictAbsorbed, contentRefreshed };
|
|
1461
|
+
}
|
|
1462
|
+
readFernignorePatterns() {
|
|
1463
|
+
const fernignorePath = (0, import_node_path3.join)(this.outputDir, ".fernignore");
|
|
1464
|
+
if (!(0, import_node_fs2.existsSync)(fernignorePath)) return [];
|
|
1465
|
+
return (0, import_node_fs2.readFileSync)(fernignorePath, "utf-8").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
1466
|
+
}
|
|
1467
|
+
buildReport(flow, patches, results, options, warnings, rebaseCounts, preRebaseCounts) {
|
|
1468
|
+
const conflictResults = results.filter((r) => r.status === "conflict");
|
|
1469
|
+
const conflictDetails = conflictResults.map((r) => {
|
|
1470
|
+
const conflictFiles = r.fileResults?.filter((f) => f.status === "conflict") ?? [];
|
|
1471
|
+
const cleanFiles = r.fileResults?.filter((f) => f.status === "merged").map((f) => f.file) ?? [];
|
|
1472
|
+
return {
|
|
1473
|
+
patchId: r.patch.id,
|
|
1474
|
+
patchMessage: r.patch.original_message,
|
|
1475
|
+
reason: r.conflictReason,
|
|
1476
|
+
files: conflictFiles,
|
|
1477
|
+
cleanFiles: cleanFiles.length > 0 ? cleanFiles : void 0
|
|
1478
|
+
};
|
|
1479
|
+
}).filter((d) => d.files.length > 0);
|
|
1480
|
+
const partialCount = conflictDetails.filter((d) => d.cleanFiles && d.cleanFiles.length > 0).length;
|
|
1481
|
+
return {
|
|
1482
|
+
flow,
|
|
1483
|
+
patchesDetected: patches.length,
|
|
1484
|
+
patchesApplied: results.filter((r) => r.status === "applied").length,
|
|
1485
|
+
patchesWithConflicts: conflictResults.length,
|
|
1486
|
+
patchesSkipped: results.filter((r) => r.status === "skipped").length,
|
|
1487
|
+
patchesPartiallyApplied: partialCount > 0 ? partialCount : void 0,
|
|
1488
|
+
patchesAbsorbed: rebaseCounts && rebaseCounts.absorbed > 0 ? rebaseCounts.absorbed : void 0,
|
|
1489
|
+
patchesRepointed: rebaseCounts && rebaseCounts.repointed > 0 ? rebaseCounts.repointed : void 0,
|
|
1490
|
+
patchesContentRebased: rebaseCounts && rebaseCounts.contentRebased > 0 ? rebaseCounts.contentRebased : void 0,
|
|
1491
|
+
patchesKeptAsUserOwned: rebaseCounts && rebaseCounts.keptAsUserOwned > 0 ? rebaseCounts.keptAsUserOwned : void 0,
|
|
1492
|
+
patchesConflictResolved: preRebaseCounts && preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed > 0 ? preRebaseCounts.conflictResolved + preRebaseCounts.conflictAbsorbed : void 0,
|
|
1493
|
+
patchesRefreshed: preRebaseCounts && preRebaseCounts.contentRefreshed > 0 ? preRebaseCounts.contentRefreshed : void 0,
|
|
1494
|
+
conflicts: conflictResults.flatMap((r) => r.fileResults?.filter((f) => f.status === "conflict") ?? []),
|
|
1495
|
+
conflictDetails: conflictDetails.length > 0 ? conflictDetails : void 0,
|
|
1496
|
+
wouldApply: options?.dryRun ? patches : void 0,
|
|
1497
|
+
warnings: warnings && warnings.length > 0 ? warnings : void 0
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
// src/FernignoreMigrator.ts
|
|
1503
|
+
var import_node_crypto2 = require("crypto");
|
|
1504
|
+
var import_node_fs3 = require("fs");
|
|
1505
|
+
var import_node_path4 = require("path");
|
|
1506
|
+
var import_minimatch3 = require("minimatch");
|
|
1507
|
+
var import_yaml2 = require("yaml");
|
|
1508
|
+
var FernignoreMigrator = class {
|
|
1509
|
+
git;
|
|
1510
|
+
lockManager;
|
|
1511
|
+
outputDir;
|
|
1512
|
+
constructor(git, lockManager, outputDir) {
|
|
1513
|
+
this.git = git;
|
|
1514
|
+
this.lockManager = lockManager;
|
|
1515
|
+
this.outputDir = outputDir;
|
|
1516
|
+
}
|
|
1517
|
+
fernignoreExists() {
|
|
1518
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path4.join)(this.outputDir, ".fernignore"));
|
|
1519
|
+
}
|
|
1520
|
+
readFernignorePatterns() {
|
|
1521
|
+
const fernignorePath = (0, import_node_path4.join)(this.outputDir, ".fernignore");
|
|
1522
|
+
if (!(0, import_node_fs3.existsSync)(fernignorePath)) {
|
|
1523
|
+
return [];
|
|
1524
|
+
}
|
|
1525
|
+
const content = (0, import_node_fs3.readFileSync)(fernignorePath, "utf-8");
|
|
1526
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
1527
|
+
}
|
|
1528
|
+
/** Analyze .fernignore patterns vs git history. Creates synthetic patches for files differing from pristine generation. */
|
|
1529
|
+
async analyzeMigration() {
|
|
1530
|
+
const patterns = this.readFernignorePatterns();
|
|
1531
|
+
const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
|
|
1532
|
+
const patches = await detector.detectNewPatches();
|
|
1533
|
+
const trackedByBoth = [];
|
|
1534
|
+
const fernignoreOnly = [];
|
|
1535
|
+
const commitsOnly = [];
|
|
1536
|
+
const syntheticPatches = [];
|
|
1537
|
+
for (const pattern of patterns) {
|
|
1538
|
+
const matchingPatch = patches.find((p) => p.files.some((f) => (0, import_minimatch3.minimatch)(f, pattern) || f === pattern));
|
|
1539
|
+
if (matchingPatch) {
|
|
1540
|
+
trackedByBoth.push({
|
|
1541
|
+
file: pattern,
|
|
1542
|
+
commit: matchingPatch.original_commit
|
|
1543
|
+
});
|
|
1544
|
+
} else {
|
|
1545
|
+
fernignoreOnly.push(pattern);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
const lock = this.lockManager.read();
|
|
1549
|
+
const currentGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1550
|
+
if (currentGen && fernignoreOnly.length > 0) {
|
|
1551
|
+
const resolvedFiles = await this.resolvePatterns(fernignoreOnly);
|
|
1552
|
+
const remainingFernignoreOnly = [];
|
|
1553
|
+
for (const { pattern, files } of resolvedFiles) {
|
|
1554
|
+
const patchFiles = [];
|
|
1555
|
+
const diffParts = [];
|
|
1556
|
+
for (const filePath of files) {
|
|
1557
|
+
const pristine = await this.git.showFile(currentGen.tree_hash, filePath);
|
|
1558
|
+
const currentContent = this.readFileContent(filePath);
|
|
1559
|
+
if (currentContent === null) {
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
if (pristine === null) {
|
|
1563
|
+
patchFiles.push(filePath);
|
|
1564
|
+
diffParts.push(this.createNewFileDiff(filePath, currentContent));
|
|
1565
|
+
} else if (pristine !== currentContent) {
|
|
1566
|
+
patchFiles.push(filePath);
|
|
1567
|
+
diffParts.push(this.createFileDiff(filePath, pristine, currentContent));
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
if (patchFiles.length > 0) {
|
|
1571
|
+
const patchContent = diffParts.join("\n");
|
|
1572
|
+
const contentHash = `sha256:${(0, import_node_crypto2.createHash)("sha256").update(patchContent).digest("hex")}`;
|
|
1573
|
+
syntheticPatches.push({
|
|
1574
|
+
id: `patch-fernignore-${(0, import_node_crypto2.createHash)("sha256").update(pattern).digest("hex").slice(0, 8)}`,
|
|
1575
|
+
content_hash: contentHash,
|
|
1576
|
+
original_commit: currentGen.commit_sha,
|
|
1577
|
+
original_message: `[fernignore-migration] Customizations for ${pattern}`,
|
|
1578
|
+
original_author: "Fern Replay <replay@buildwithfern.com>",
|
|
1579
|
+
base_generation: currentGen.commit_sha,
|
|
1580
|
+
files: patchFiles,
|
|
1581
|
+
patch_content: patchContent
|
|
1582
|
+
});
|
|
1583
|
+
trackedByBoth.push({
|
|
1584
|
+
file: pattern,
|
|
1585
|
+
commit: `synthetic (differs from generated)`
|
|
1586
|
+
});
|
|
1587
|
+
} else {
|
|
1588
|
+
remainingFernignoreOnly.push(pattern);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
fernignoreOnly.length = 0;
|
|
1592
|
+
fernignoreOnly.push(...remainingFernignoreOnly);
|
|
1593
|
+
}
|
|
1594
|
+
for (const patch of patches) {
|
|
1595
|
+
const hasUnprotectedFiles = patch.files.some((f) => !patterns.some((p) => (0, import_minimatch3.minimatch)(f, p) || f === p));
|
|
1596
|
+
if (hasUnprotectedFiles) {
|
|
1597
|
+
commitsOnly.push(patch);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return { trackedByBoth, fernignoreOnly, commitsOnly, syntheticPatches };
|
|
1601
|
+
}
|
|
1602
|
+
async resolvePatterns(patterns) {
|
|
1603
|
+
const allFiles = (await this.git.exec(["ls-files"])).trim().split("\n").filter(Boolean);
|
|
1604
|
+
const results = [];
|
|
1605
|
+
for (const pattern of patterns) {
|
|
1606
|
+
const matching = allFiles.filter(
|
|
1607
|
+
(f) => (0, import_minimatch3.minimatch)(f, pattern) || f === pattern || f.startsWith(pattern + "/")
|
|
1608
|
+
);
|
|
1609
|
+
results.push({ pattern, files: matching.length > 0 ? matching : [pattern] });
|
|
1610
|
+
}
|
|
1611
|
+
return results;
|
|
1612
|
+
}
|
|
1613
|
+
readFileContent(filePath) {
|
|
1614
|
+
const fullPath = (0, import_node_path4.join)(this.outputDir, filePath);
|
|
1615
|
+
if (!(0, import_node_fs3.existsSync)(fullPath)) {
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
try {
|
|
1619
|
+
const stat = (0, import_node_fs3.statSync)(fullPath);
|
|
1620
|
+
if (stat.isDirectory()) {
|
|
1621
|
+
return null;
|
|
1622
|
+
}
|
|
1623
|
+
return (0, import_node_fs3.readFileSync)(fullPath, "utf-8");
|
|
1624
|
+
} catch {
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
createNewFileDiff(filePath, content) {
|
|
1629
|
+
const lines = content.split("\n");
|
|
1630
|
+
const hunks = lines.map((l) => `+${l}`).join("\n");
|
|
1631
|
+
return [
|
|
1632
|
+
`diff --git a/${filePath} b/${filePath}`,
|
|
1633
|
+
"new file mode 100644",
|
|
1634
|
+
`--- /dev/null`,
|
|
1635
|
+
`+++ b/${filePath}`,
|
|
1636
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
1637
|
+
hunks
|
|
1638
|
+
].join("\n");
|
|
1639
|
+
}
|
|
1640
|
+
createFileDiff(filePath, pristine, current) {
|
|
1641
|
+
const oldLines = pristine.split("\n");
|
|
1642
|
+
const newLines = current.split("\n");
|
|
1643
|
+
const removals = oldLines.map((l) => `-${l}`).join("\n");
|
|
1644
|
+
const additions = newLines.map((l) => `+${l}`).join("\n");
|
|
1645
|
+
return [
|
|
1646
|
+
`diff --git a/${filePath} b/${filePath}`,
|
|
1647
|
+
`--- a/${filePath}`,
|
|
1648
|
+
`+++ b/${filePath}`,
|
|
1649
|
+
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
|
|
1650
|
+
removals,
|
|
1651
|
+
additions
|
|
1652
|
+
].join("\n");
|
|
1653
|
+
}
|
|
1654
|
+
async migrate() {
|
|
1655
|
+
const analysis = await this.analyzeMigration();
|
|
1656
|
+
const detector = new ReplayDetector(this.git, this.lockManager, this.outputDir);
|
|
1657
|
+
const patches = await detector.detectNewPatches();
|
|
1658
|
+
const warnings = [];
|
|
1659
|
+
let patchesCreated = 0;
|
|
1660
|
+
for (const patch of patches) {
|
|
1661
|
+
this.lockManager.addPatch(patch);
|
|
1662
|
+
patchesCreated++;
|
|
1663
|
+
}
|
|
1664
|
+
if (patchesCreated > 0) {
|
|
1665
|
+
this.lockManager.save();
|
|
1666
|
+
}
|
|
1667
|
+
for (const file of analysis.fernignoreOnly) {
|
|
1668
|
+
warnings.push(
|
|
1669
|
+
`${file}: in .fernignore but no commit history found. Commit this file or keep in .fernignore as fallback.`
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
return {
|
|
1673
|
+
patchesCreated,
|
|
1674
|
+
filesSkipped: analysis.fernignoreOnly,
|
|
1675
|
+
warnings
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
movePatternsToReplayYml(patterns) {
|
|
1679
|
+
const replayYmlPath = (0, import_node_path4.join)(this.outputDir, ".fern", "replay.yml");
|
|
1680
|
+
let config = {};
|
|
1681
|
+
if ((0, import_node_fs3.existsSync)(replayYmlPath)) {
|
|
1682
|
+
const content = (0, import_node_fs3.readFileSync)(replayYmlPath, "utf-8");
|
|
1683
|
+
config = (0, import_yaml2.parse)(content) ?? {};
|
|
1684
|
+
}
|
|
1685
|
+
const existing = config.exclude ?? [];
|
|
1686
|
+
const merged = [.../* @__PURE__ */ new Set([...existing, ...patterns])];
|
|
1687
|
+
config.exclude = merged;
|
|
1688
|
+
const dir = (0, import_node_path4.dirname)(replayYmlPath);
|
|
1689
|
+
if (!(0, import_node_fs3.existsSync)(dir)) {
|
|
1690
|
+
(0, import_node_fs3.mkdirSync)(dir, { recursive: true });
|
|
1691
|
+
}
|
|
1692
|
+
(0, import_node_fs3.writeFileSync)(replayYmlPath, (0, import_yaml2.stringify)(config, { lineWidth: 0 }), "utf-8");
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
// src/commands/bootstrap.ts
|
|
1697
|
+
var import_node_crypto3 = require("crypto");
|
|
1698
|
+
var import_node_fs4 = require("fs");
|
|
1699
|
+
var import_node_path5 = require("path");
|
|
1700
|
+
init_GitClient();
|
|
1701
|
+
async function bootstrap(outputDir, options) {
|
|
1702
|
+
const git = new GitClient(outputDir);
|
|
1703
|
+
const lockManager = new LockfileManager(outputDir);
|
|
1704
|
+
const maxCommits = options?.maxCommitsToScan ?? 500;
|
|
1705
|
+
const warnings = [];
|
|
1706
|
+
if (lockManager.exists() && !options?.force) {
|
|
1707
|
+
return {
|
|
1708
|
+
generationCommit: null,
|
|
1709
|
+
patchesDetected: 0,
|
|
1710
|
+
patchesCreated: 0,
|
|
1711
|
+
patches: [],
|
|
1712
|
+
fernignorePatterns: [],
|
|
1713
|
+
fernignoreUpdated: false,
|
|
1714
|
+
warnings: ["Replay lockfile already exists. Use --force to overwrite."],
|
|
1715
|
+
staleGenerationsSkipped: 0,
|
|
1716
|
+
scannedSinceGeneration: ""
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
const genCommits = await findAllGenerationCommits(git, maxCommits);
|
|
1720
|
+
if (genCommits.length === 0) {
|
|
1721
|
+
return {
|
|
1722
|
+
generationCommit: null,
|
|
1723
|
+
patchesDetected: 0,
|
|
1724
|
+
patchesCreated: 0,
|
|
1725
|
+
patches: [],
|
|
1726
|
+
fernignorePatterns: [],
|
|
1727
|
+
fernignoreUpdated: false,
|
|
1728
|
+
warnings: [
|
|
1729
|
+
"No generation commits found in the last " + maxCommits + " commits. Run 'fern generate' first to establish a baseline."
|
|
1730
|
+
],
|
|
1731
|
+
staleGenerationsSkipped: 0,
|
|
1732
|
+
scannedSinceGeneration: ""
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
const latestGen = genCommits[0];
|
|
1736
|
+
const anchorSha = options?.importHistory ? latestGen.sha : (await git.exec(["rev-parse", "HEAD"])).trim();
|
|
1737
|
+
const treeHash = await git.getTreeHash(anchorSha);
|
|
1738
|
+
const genRecord = {
|
|
1739
|
+
commit_sha: anchorSha,
|
|
1740
|
+
tree_hash: treeHash,
|
|
1741
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1742
|
+
cli_version: "unknown",
|
|
1743
|
+
generator_versions: {}
|
|
1744
|
+
};
|
|
1745
|
+
lockManager.initializeInMemory(genRecord);
|
|
1746
|
+
const patches = options?.importHistory ? await findAllUserPatches(git, genCommits) : [];
|
|
1747
|
+
const migrator = new FernignoreMigrator(git, lockManager, outputDir);
|
|
1748
|
+
const fernignorePatterns = migrator.readFernignorePatterns();
|
|
1749
|
+
let fernignoreAnalysis;
|
|
1750
|
+
if (options?.importHistory && migrator.fernignoreExists() && fernignorePatterns.length > 0) {
|
|
1751
|
+
fernignoreAnalysis = await migrator.analyzeMigration();
|
|
1752
|
+
if (fernignoreAnalysis.syntheticPatches.length > 0) {
|
|
1753
|
+
patches.push(...fernignoreAnalysis.syntheticPatches);
|
|
1754
|
+
}
|
|
1755
|
+
if (fernignoreAnalysis.fernignoreOnly.length > 0) {
|
|
1756
|
+
for (const file of fernignoreAnalysis.fernignoreOnly) {
|
|
1757
|
+
warnings.push(
|
|
1758
|
+
`${file}: in .fernignore but no recent commits found since last generation. File may be stale, matches generated output, or does not exist. No customization to track.`
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
if (options?.dryRun) {
|
|
1764
|
+
return {
|
|
1765
|
+
generationCommit: latestGen,
|
|
1766
|
+
patchesDetected: patches.length,
|
|
1767
|
+
patchesCreated: 0,
|
|
1768
|
+
patches,
|
|
1769
|
+
fernignorePatterns,
|
|
1770
|
+
fernignoreAnalysis,
|
|
1771
|
+
fernignoreUpdated: false,
|
|
1772
|
+
warnings,
|
|
1773
|
+
staleGenerationsSkipped: genCommits.length - 1,
|
|
1774
|
+
scannedSinceGeneration: latestGen.sha
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
for (const patch of patches) {
|
|
1778
|
+
lockManager.addPatch(patch);
|
|
1779
|
+
}
|
|
1780
|
+
lockManager.save();
|
|
1781
|
+
const fernignoreUpdated = ensureFernignoreEntries(outputDir);
|
|
1782
|
+
if (migrator.fernignoreExists() && fernignorePatterns.length > 0) {
|
|
1783
|
+
const action = options?.fernignoreAction ?? "skip";
|
|
1784
|
+
if (action === "migrate") {
|
|
1785
|
+
const patternsToExclude = fernignoreAnalysis?.fernignoreOnly ?? [];
|
|
1786
|
+
if (patternsToExclude.length > 0) {
|
|
1787
|
+
migrator.movePatternsToReplayYml(patternsToExclude);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return {
|
|
1792
|
+
generationCommit: latestGen,
|
|
1793
|
+
patchesDetected: patches.length,
|
|
1794
|
+
patchesCreated: patches.length,
|
|
1795
|
+
patches,
|
|
1796
|
+
fernignorePatterns,
|
|
1797
|
+
fernignoreAnalysis,
|
|
1798
|
+
fernignoreUpdated,
|
|
1799
|
+
warnings,
|
|
1800
|
+
staleGenerationsSkipped: genCommits.length - 1,
|
|
1801
|
+
scannedSinceGeneration: latestGen.sha
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
async function findAllGenerationCommits(git, maxCommits) {
|
|
1805
|
+
const log = await git.exec(["log", "--format=%H%x00%an%x00%ae%x00%s", `-${maxCommits}`]);
|
|
1806
|
+
if (!log.trim()) {
|
|
1807
|
+
return [];
|
|
1808
|
+
}
|
|
1809
|
+
const genCommits = [];
|
|
1810
|
+
for (const line of log.trim().split("\n")) {
|
|
1811
|
+
if (!line) continue;
|
|
1812
|
+
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
1813
|
+
const commit = { sha, authorName, authorEmail, message };
|
|
1814
|
+
if (isGenerationCommit(commit) && !isReplayCommit(commit)) {
|
|
1815
|
+
genCommits.push(commit);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return genCommits;
|
|
1819
|
+
}
|
|
1820
|
+
async function findAllUserPatches(git, genCommits) {
|
|
1821
|
+
const patches = [];
|
|
1822
|
+
const seenHashes = /* @__PURE__ */ new Set();
|
|
1823
|
+
const latestGen = genCommits[0];
|
|
1824
|
+
const recentPatches = await extractUserPatches(git, latestGen.sha, "HEAD", latestGen.sha, seenHashes);
|
|
1825
|
+
patches.push(...recentPatches);
|
|
1826
|
+
return patches;
|
|
1827
|
+
}
|
|
1828
|
+
async function extractUserPatches(git, fromSha, toRef, baseGeneration, seenHashes) {
|
|
1829
|
+
const log = await git.exec(["log", "--format=%H%x00%an%x00%ae%x00%s", `${fromSha}..${toRef}`]);
|
|
1830
|
+
if (!log.trim()) {
|
|
1831
|
+
return [];
|
|
1832
|
+
}
|
|
1833
|
+
const commits = parseGitLog(log);
|
|
1834
|
+
const patches = [];
|
|
1835
|
+
for (const commit of commits.reverse()) {
|
|
1836
|
+
if (isGenerationCommit(commit)) continue;
|
|
1837
|
+
const parents = await git.getCommitParents(commit.sha);
|
|
1838
|
+
if (parents.length > 1) continue;
|
|
1839
|
+
const patchContent = await git.formatPatch(commit.sha);
|
|
1840
|
+
const contentHash = computeContentHash(patchContent);
|
|
1841
|
+
if (seenHashes.has(contentHash)) continue;
|
|
1842
|
+
seenHashes.add(contentHash);
|
|
1843
|
+
const filesOutput = await git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
1844
|
+
patches.push({
|
|
1845
|
+
id: `patch-${commit.sha.slice(0, 8)}`,
|
|
1846
|
+
content_hash: contentHash,
|
|
1847
|
+
original_commit: commit.sha,
|
|
1848
|
+
original_message: commit.message,
|
|
1849
|
+
original_author: `${commit.authorName} <${commit.authorEmail}>`,
|
|
1850
|
+
base_generation: baseGeneration,
|
|
1851
|
+
files: filesOutput.trim().split("\n").filter(Boolean),
|
|
1852
|
+
patch_content: patchContent
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
return patches;
|
|
1856
|
+
}
|
|
1857
|
+
function parseGitLog(log) {
|
|
1858
|
+
return log.trim().split("\n").filter(Boolean).map((line) => {
|
|
1859
|
+
const [sha, authorName, authorEmail, message] = line.split("\0");
|
|
1860
|
+
return { sha, authorName, authorEmail, message };
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
var REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml"];
|
|
1864
|
+
function ensureFernignoreEntries(outputDir) {
|
|
1865
|
+
const fernignorePath = (0, import_node_path5.join)(outputDir, ".fernignore");
|
|
1866
|
+
let content = "";
|
|
1867
|
+
if ((0, import_node_fs4.existsSync)(fernignorePath)) {
|
|
1868
|
+
content = (0, import_node_fs4.readFileSync)(fernignorePath, "utf-8");
|
|
1869
|
+
}
|
|
1870
|
+
const lines = content.split("\n");
|
|
1871
|
+
const toAdd = [];
|
|
1872
|
+
for (const entry of REPLAY_FERNIGNORE_ENTRIES) {
|
|
1873
|
+
if (!lines.some((line) => line.trim() === entry)) {
|
|
1874
|
+
toAdd.push(entry);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
if (toAdd.length === 0) {
|
|
1878
|
+
return false;
|
|
1879
|
+
}
|
|
1880
|
+
if (content && !content.endsWith("\n")) {
|
|
1881
|
+
content += "\n";
|
|
1882
|
+
}
|
|
1883
|
+
content += toAdd.join("\n") + "\n";
|
|
1884
|
+
(0, import_node_fs4.writeFileSync)(fernignorePath, content, "utf-8");
|
|
1885
|
+
return true;
|
|
1886
|
+
}
|
|
1887
|
+
function computeContentHash(patchContent) {
|
|
1888
|
+
const normalized = patchContent.split("\n").filter((line) => !line.startsWith("From ") && !line.startsWith("index ") && !line.startsWith("Date: ")).join("\n");
|
|
1889
|
+
return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex")}`;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// src/commands/forget.ts
|
|
1893
|
+
var import_minimatch4 = require("minimatch");
|
|
1894
|
+
function forget(outputDir, filePattern, options) {
|
|
1895
|
+
const lockManager = new LockfileManager(outputDir);
|
|
1896
|
+
if (!lockManager.exists()) {
|
|
1897
|
+
return { removed: [], notFound: true };
|
|
1898
|
+
}
|
|
1899
|
+
const lock = lockManager.read();
|
|
1900
|
+
const matchingPatches = lock.patches.filter(
|
|
1901
|
+
(patch) => patch.files.some((file) => file === filePattern || (0, import_minimatch4.minimatch)(file, filePattern))
|
|
1902
|
+
);
|
|
1903
|
+
if (matchingPatches.length === 0) {
|
|
1904
|
+
return { removed: [], notFound: true };
|
|
1905
|
+
}
|
|
1906
|
+
const removed = matchingPatches.map((p) => ({
|
|
1907
|
+
id: p.id,
|
|
1908
|
+
message: p.original_message,
|
|
1909
|
+
files: p.files
|
|
1910
|
+
}));
|
|
1911
|
+
if (!options?.dryRun) {
|
|
1912
|
+
for (const patch of matchingPatches) {
|
|
1913
|
+
lockManager.removePatch(patch.id);
|
|
1914
|
+
}
|
|
1915
|
+
lockManager.save();
|
|
1916
|
+
}
|
|
1917
|
+
return { removed, notFound: false };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// src/commands/reset.ts
|
|
1921
|
+
var import_node_fs5 = require("fs");
|
|
1922
|
+
function reset(outputDir, options) {
|
|
1923
|
+
const lockManager = new LockfileManager(outputDir);
|
|
1924
|
+
if (!lockManager.exists()) {
|
|
1925
|
+
return {
|
|
1926
|
+
success: true,
|
|
1927
|
+
patchesRemoved: 0,
|
|
1928
|
+
lockfileDeleted: false,
|
|
1929
|
+
nothingToReset: true
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
const lock = lockManager.read();
|
|
1933
|
+
const patchCount = lock.patches.length;
|
|
1934
|
+
if (!options?.dryRun) {
|
|
1935
|
+
(0, import_node_fs5.unlinkSync)(lockManager.lockfilePath);
|
|
1936
|
+
}
|
|
1937
|
+
return {
|
|
1938
|
+
success: true,
|
|
1939
|
+
patchesRemoved: patchCount,
|
|
1940
|
+
lockfileDeleted: true,
|
|
1941
|
+
nothingToReset: false
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/commands/resolve.ts
|
|
1946
|
+
init_GitClient();
|
|
1947
|
+
async function resolve(outputDir, options) {
|
|
1948
|
+
const lockManager = new LockfileManager(outputDir);
|
|
1949
|
+
if (!lockManager.exists()) {
|
|
1950
|
+
return { success: false, reason: "no-lockfile" };
|
|
1951
|
+
}
|
|
1952
|
+
const lock = lockManager.read();
|
|
1953
|
+
if (lock.patches.length === 0) {
|
|
1954
|
+
return { success: false, reason: "no-patches" };
|
|
1955
|
+
}
|
|
1956
|
+
const git = new GitClient(outputDir);
|
|
1957
|
+
if (options?.checkMarkers !== false) {
|
|
1958
|
+
const markerFiles = await git.exec(["grep", "-l", "<<<<<<<", "--", "."]).catch(() => "");
|
|
1959
|
+
if (markerFiles.trim()) {
|
|
1960
|
+
const files = markerFiles.trim().split("\n").filter(Boolean);
|
|
1961
|
+
return { success: false, reason: "unresolved-conflicts", unresolvedFiles: files };
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
const committer = new ReplayCommitter(git, outputDir);
|
|
1965
|
+
await committer.stageAll();
|
|
1966
|
+
const commitSha = await committer.commitReplay(lock.patches.length, lock.patches);
|
|
1967
|
+
return { success: true, commitSha };
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// src/commands/status.ts
|
|
1971
|
+
function status(outputDir) {
|
|
1972
|
+
const lockManager = new LockfileManager(outputDir);
|
|
1973
|
+
if (!lockManager.exists()) {
|
|
1974
|
+
return { initialized: false, patches: [], lastGeneration: void 0 };
|
|
1975
|
+
}
|
|
1976
|
+
const lock = lockManager.read();
|
|
1977
|
+
const patches = lock.patches.map((patch) => ({
|
|
1978
|
+
sha: patch.original_commit.slice(0, 7),
|
|
1979
|
+
author: patch.original_author.split("<")[0]?.trim() ?? "unknown",
|
|
1980
|
+
message: patch.original_message,
|
|
1981
|
+
files: patch.files
|
|
1982
|
+
}));
|
|
1983
|
+
let lastGeneration;
|
|
1984
|
+
const lastGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1985
|
+
if (lastGen) {
|
|
1986
|
+
lastGeneration = {
|
|
1987
|
+
sha: lastGen.commit_sha,
|
|
1988
|
+
timestamp: lastGen.timestamp
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
return { initialized: true, patches, lastGeneration };
|
|
1992
|
+
}
|
|
1993
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1994
|
+
0 && (module.exports = {
|
|
1995
|
+
FERN_BOT_EMAIL,
|
|
1996
|
+
FERN_BOT_LOGIN,
|
|
1997
|
+
FERN_BOT_NAME,
|
|
1998
|
+
FernignoreMigrator,
|
|
1999
|
+
GitClient,
|
|
2000
|
+
LockfileManager,
|
|
2001
|
+
ReplayApplicator,
|
|
2002
|
+
ReplayCommitter,
|
|
2003
|
+
ReplayDetector,
|
|
2004
|
+
ReplayService,
|
|
2005
|
+
bootstrap,
|
|
2006
|
+
forget,
|
|
2007
|
+
isGenerationCommit,
|
|
2008
|
+
isReplayCommit,
|
|
2009
|
+
reset,
|
|
2010
|
+
resolve,
|
|
2011
|
+
status,
|
|
2012
|
+
threeWayMerge
|
|
2013
|
+
});
|
|
2014
|
+
//# sourceMappingURL=index.cjs.map
|