@fern-api/replay 0.15.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +90 -125
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +90 -125
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -37
- package/dist/index.d.ts +24 -37
- package/dist/index.js +90 -125
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -554,9 +554,22 @@ function isGenerationCommit(commit) {
|
|
|
554
554
|
const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.startsWith("[fern-autoversion]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
|
|
555
555
|
// The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
|
|
556
556
|
// GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
|
|
557
|
-
commit.message.startsWith("SDK Generation")
|
|
557
|
+
commit.message.startsWith("SDK Generation") || // Merge commit (GitHub's "Create a merge commit" option) of a fern-bot
|
|
558
|
+
// regen PR. First-parent walking lands on this commit, not on the
|
|
559
|
+
// generation commit on the side branch, so the classifier must treat
|
|
560
|
+
// it as a generation boundary. GitHub's default subject is
|
|
561
|
+
// "Merge pull request #N from <owner>/<branch>"; fern-bot branches are
|
|
562
|
+
// named "<owner>/fern-bot/<...>".
|
|
563
|
+
/^Merge pull request #\d+ from [^/]+\/fern-bot\//.test(commit.message);
|
|
558
564
|
return isBotAuthor || hasGenerationMarker;
|
|
559
565
|
}
|
|
566
|
+
function isGenerationBoundary(commit) {
|
|
567
|
+
const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
|
|
568
|
+
const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
|
|
569
|
+
const isAutoversion = commit.message.startsWith("[fern-autoversion]");
|
|
570
|
+
const hasBoundaryMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || commit.message.startsWith("SDK Generation");
|
|
571
|
+
return isBotAuthor && !isAutoversion || hasBoundaryMarker;
|
|
572
|
+
}
|
|
560
573
|
function isReplayCommit(commit) {
|
|
561
574
|
return commit.message.startsWith("[fern-replay]");
|
|
562
575
|
}
|
|
@@ -884,8 +897,48 @@ var ReplayDetector = class {
|
|
|
884
897
|
this.lockManager = lockManager;
|
|
885
898
|
this.sdkOutputDir = sdkOutputDir;
|
|
886
899
|
}
|
|
900
|
+
/**
|
|
901
|
+
* Derive the previous-generation SHA from git history instead of trusting
|
|
902
|
+
* `lock.current_generation`.
|
|
903
|
+
*
|
|
904
|
+
* Walks `git log HEAD --first-parent` and returns the SHA of the most recent
|
|
905
|
+
* commit that `isGenerationBoundary` classifies as a customization-baseline
|
|
906
|
+
* boundary, or null if no such commit is reachable from HEAD on the mainline.
|
|
907
|
+
*
|
|
908
|
+
* `isGenerationBoundary` is intentionally narrower than `isGenerationCommit`:
|
|
909
|
+
* it excludes `[fern-autoversion]` (lightweight bumps that don't reset the
|
|
910
|
+
* baseline) and the GitHub merge-commit subject for fern-bot regen PRs
|
|
911
|
+
* (which sits on first-parent but where the customer fix-up commits live
|
|
912
|
+
* on second-parent — stopping at the merge would hide them).
|
|
913
|
+
*
|
|
914
|
+
* See docs/adr/0001-derived-scan-boundary.md.
|
|
915
|
+
*/
|
|
916
|
+
async findPreviousGenerationFromHistory() {
|
|
917
|
+
let log;
|
|
918
|
+
try {
|
|
919
|
+
log = await this.git.exec([
|
|
920
|
+
"log",
|
|
921
|
+
"--first-parent",
|
|
922
|
+
"--format=%H%x00%an%x00%ae%x00%s",
|
|
923
|
+
"HEAD"
|
|
924
|
+
]);
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
for (const commit of this.parseGitLog(log)) {
|
|
929
|
+
if (isGenerationBoundary(commit)) {
|
|
930
|
+
return commit.sha;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
887
935
|
async detectNewPatches() {
|
|
888
936
|
const lock = this.lockManager.read();
|
|
937
|
+
const derivedSha = await this.findPreviousGenerationFromHistory();
|
|
938
|
+
if (derivedSha !== null) {
|
|
939
|
+
const derivedRecord = await this.synthesizeGenerationRecord(derivedSha);
|
|
940
|
+
return this.detectPatchesInRange(derivedSha, derivedRecord, lock);
|
|
941
|
+
}
|
|
889
942
|
const lastGen = this.getLastGeneration(lock);
|
|
890
943
|
if (!lastGen) {
|
|
891
944
|
return { patches: [], revertedPatchIds: [] };
|
|
@@ -893,7 +946,7 @@ var ReplayDetector = class {
|
|
|
893
946
|
const exists = await this.git.commitExists(lastGen.commit_sha);
|
|
894
947
|
if (!exists) {
|
|
895
948
|
this.warnings.push(
|
|
896
|
-
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to
|
|
949
|
+
`Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
|
|
897
950
|
);
|
|
898
951
|
return this.detectPatchesViaTreeDiff(
|
|
899
952
|
lastGen,
|
|
@@ -901,44 +954,8 @@ var ReplayDetector = class {
|
|
|
901
954
|
true
|
|
902
955
|
);
|
|
903
956
|
}
|
|
904
|
-
const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
|
|
905
|
-
if (!isAncestor) {
|
|
906
|
-
return this.detectPatchesViaMergeBase(lastGen, lock);
|
|
907
|
-
}
|
|
908
957
|
return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
|
|
909
958
|
}
|
|
910
|
-
/**
|
|
911
|
-
* Non-linear-history detection via `merge-base(prevGen, HEAD)`.
|
|
912
|
-
*
|
|
913
|
-
* After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
|
|
914
|
-
* `[fern-generated]`) is orphaned but still in git's object database. The
|
|
915
|
-
* merge-base is the commit on main from which the bot's branch was created —
|
|
916
|
-
* a reachable ancestor of HEAD. Walking that range and classifying each
|
|
917
|
-
* commit via `isGenerationCommit` mirrors the linear path and eliminates
|
|
918
|
-
* the bug class where pipeline-driven changes (autoversion, replay,
|
|
919
|
-
* generation) get bundled as customer customizations.
|
|
920
|
-
*
|
|
921
|
-
* Falls through to the legacy composite path when no common ancestor exists
|
|
922
|
-
* (truly disjoint history — orphan branches with no shared root).
|
|
923
|
-
*/
|
|
924
|
-
async detectPatchesViaMergeBase(lastGen, lock) {
|
|
925
|
-
let mergeBase = "";
|
|
926
|
-
try {
|
|
927
|
-
mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
|
|
928
|
-
} catch {
|
|
929
|
-
}
|
|
930
|
-
if (!mergeBase) {
|
|
931
|
-
this.warnings.push(
|
|
932
|
-
`No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
|
|
933
|
-
);
|
|
934
|
-
return this.detectPatchesViaTreeDiff(
|
|
935
|
-
lastGen,
|
|
936
|
-
/* commitKnownMissing */
|
|
937
|
-
false
|
|
938
|
-
);
|
|
939
|
-
}
|
|
940
|
-
return this.detectPatchesInRange(mergeBase, lastGen, lock);
|
|
941
|
-
}
|
|
942
959
|
/**
|
|
943
960
|
* Walk `${rangeStart}..HEAD`, classify each commit, emit one
|
|
944
961
|
* `StoredPatch` per customer commit. Body shared by the linear path
|
|
@@ -1374,6 +1391,26 @@ var ReplayDetector = class {
|
|
|
1374
1391
|
getLastGeneration(lock) {
|
|
1375
1392
|
return lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
1376
1393
|
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Build a transient `GenerationRecord` from a SHA derived by walking git
|
|
1396
|
+
* history. Only `commit_sha` (and `tree_hash` when needed downstream) is
|
|
1397
|
+
* load-bearing here; the rest are filled with defensible placeholders.
|
|
1398
|
+
* This record is consumed by `detectPatchesInRange` and is never persisted.
|
|
1399
|
+
*/
|
|
1400
|
+
async synthesizeGenerationRecord(sha) {
|
|
1401
|
+
let tree_hash = "";
|
|
1402
|
+
try {
|
|
1403
|
+
tree_hash = await this.git.getTreeHash(sha);
|
|
1404
|
+
} catch {
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
commit_sha: sha,
|
|
1408
|
+
tree_hash,
|
|
1409
|
+
timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1410
|
+
cli_version: "",
|
|
1411
|
+
generator_versions: {}
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1377
1414
|
};
|
|
1378
1415
|
|
|
1379
1416
|
// src/ThreeWayMerge.ts
|
|
@@ -2513,8 +2550,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
|
|
|
2513
2550
|
tree_hash: treeHash,
|
|
2514
2551
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2515
2552
|
cli_version: options?.cliVersion ?? "unknown",
|
|
2516
|
-
generator_versions: options?.generatorVersions ?? {}
|
|
2517
|
-
base_branch_head: options?.baseBranchHead
|
|
2553
|
+
generator_versions: options?.generatorVersions ?? {}
|
|
2518
2554
|
};
|
|
2519
2555
|
}
|
|
2520
2556
|
async stageAll() {
|
|
@@ -2901,7 +2937,6 @@ var FirstGenerationFlow = class {
|
|
|
2901
2937
|
flow: "first-generation",
|
|
2902
2938
|
previousGenerationSha: null,
|
|
2903
2939
|
currentGenerationSha: headSha,
|
|
2904
|
-
baseBranchHead: options.baseBranchHead ?? null,
|
|
2905
2940
|
_prepared: {
|
|
2906
2941
|
kind: "terminal",
|
|
2907
2942
|
flow: "first-generation",
|
|
@@ -2911,8 +2946,7 @@ var FirstGenerationFlow = class {
|
|
|
2911
2946
|
}
|
|
2912
2947
|
const commitOpts = options ? {
|
|
2913
2948
|
cliVersion: options.cliVersion ?? "unknown",
|
|
2914
|
-
generatorVersions: options.generatorVersions ?? {}
|
|
2915
|
-
baseBranchHead: options.baseBranchHead
|
|
2949
|
+
generatorVersions: options.generatorVersions ?? {}
|
|
2916
2950
|
} : void 0;
|
|
2917
2951
|
await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
|
|
2918
2952
|
const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
|
|
@@ -2921,7 +2955,6 @@ var FirstGenerationFlow = class {
|
|
|
2921
2955
|
flow: "first-generation",
|
|
2922
2956
|
previousGenerationSha: null,
|
|
2923
2957
|
currentGenerationSha: genRecord.commit_sha,
|
|
2924
|
-
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2925
2958
|
_prepared: {
|
|
2926
2959
|
kind: "active",
|
|
2927
2960
|
flow: "first-generation",
|
|
@@ -2958,8 +2991,7 @@ var SkipApplicationFlow = class {
|
|
|
2958
2991
|
async prepare(options) {
|
|
2959
2992
|
const commitOpts = options ? {
|
|
2960
2993
|
cliVersion: options.cliVersion ?? "unknown",
|
|
2961
|
-
generatorVersions: options.generatorVersions ?? {}
|
|
2962
|
-
baseBranchHead: options.baseBranchHead
|
|
2994
|
+
generatorVersions: options.generatorVersions ?? {}
|
|
2963
2995
|
} : void 0;
|
|
2964
2996
|
await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
|
|
2965
2997
|
await this.service.cleanupStaleConflictMarkers();
|
|
@@ -2981,7 +3013,6 @@ var SkipApplicationFlow = class {
|
|
|
2981
3013
|
flow: "skip-application",
|
|
2982
3014
|
previousGenerationSha,
|
|
2983
3015
|
currentGenerationSha: genRecord.commit_sha,
|
|
2984
|
-
baseBranchHead: options?.baseBranchHead ?? null,
|
|
2985
3016
|
_prepared: {
|
|
2986
3017
|
kind: "active",
|
|
2987
3018
|
flow: "skip-application",
|
|
@@ -3042,7 +3073,6 @@ var NoPatchesFlow = class {
|
|
|
3042
3073
|
flow: "no-patches",
|
|
3043
3074
|
previousGenerationSha,
|
|
3044
3075
|
currentGenerationSha: headSha,
|
|
3045
|
-
baseBranchHead: options.baseBranchHead ?? null,
|
|
3046
3076
|
_prepared: {
|
|
3047
3077
|
kind: "terminal",
|
|
3048
3078
|
flow: "no-patches",
|
|
@@ -3074,8 +3104,7 @@ var NoPatchesFlow = class {
|
|
|
3074
3104
|
}
|
|
3075
3105
|
const commitOpts = options ? {
|
|
3076
3106
|
cliVersion: options.cliVersion ?? "unknown",
|
|
3077
|
-
generatorVersions: options.generatorVersions ?? {}
|
|
3078
|
-
baseBranchHead: options.baseBranchHead
|
|
3107
|
+
generatorVersions: options.generatorVersions ?? {}
|
|
3079
3108
|
} : void 0;
|
|
3080
3109
|
await this.service.committer.commitGeneration("Update SDK", commitOpts);
|
|
3081
3110
|
await this.service.cleanupStaleConflictMarkers();
|
|
@@ -3085,7 +3114,6 @@ var NoPatchesFlow = class {
|
|
|
3085
3114
|
flow: "no-patches",
|
|
3086
3115
|
previousGenerationSha,
|
|
3087
3116
|
currentGenerationSha: genRecord.commit_sha,
|
|
3088
|
-
baseBranchHead: options?.baseBranchHead ?? null,
|
|
3089
3117
|
_prepared: {
|
|
3090
3118
|
kind: "active",
|
|
3091
3119
|
flow: "no-patches",
|
|
@@ -3175,7 +3203,6 @@ var NormalRegenerationFlow = class {
|
|
|
3175
3203
|
flow: "normal-regeneration",
|
|
3176
3204
|
previousGenerationSha,
|
|
3177
3205
|
currentGenerationSha: headSha,
|
|
3178
|
-
baseBranchHead: options.baseBranchHead ?? null,
|
|
3179
3206
|
_prepared: {
|
|
3180
3207
|
kind: "terminal",
|
|
3181
3208
|
flow: "normal-regeneration",
|
|
@@ -3193,14 +3220,16 @@ var NormalRegenerationFlow = class {
|
|
|
3193
3220
|
existingPatches = this.service.lockManager.getPatches();
|
|
3194
3221
|
const seenHashes = /* @__PURE__ */ new Map();
|
|
3195
3222
|
for (const p of existingPatches) {
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3223
|
+
if (!p.original_commit || p.original_commit === "") {
|
|
3224
|
+
const priorPatchId = seenHashes.get(p.content_hash);
|
|
3225
|
+
if (priorPatchId !== void 0) {
|
|
3226
|
+
this.service.lockManager.removePatch(p.id);
|
|
3227
|
+
this.service.warnings.push(
|
|
3228
|
+
`Consolidated patch ${p.id} into prior patch ${priorPatchId}: byte-identical patch_content. Lockfile collapsed to avoid duplicate-apply corruption on next regen.`
|
|
3229
|
+
);
|
|
3230
|
+
} else {
|
|
3231
|
+
seenHashes.set(p.content_hash, p.id);
|
|
3232
|
+
}
|
|
3204
3233
|
}
|
|
3205
3234
|
}
|
|
3206
3235
|
existingPatches = this.service.lockManager.getPatches();
|
|
@@ -3229,8 +3258,7 @@ var NormalRegenerationFlow = class {
|
|
|
3229
3258
|
const allPatches = [...existingPatches, ...newPatches];
|
|
3230
3259
|
const commitOpts = options ? {
|
|
3231
3260
|
cliVersion: options.cliVersion ?? "unknown",
|
|
3232
|
-
generatorVersions: options.generatorVersions ?? {}
|
|
3233
|
-
baseBranchHead: options.baseBranchHead
|
|
3261
|
+
generatorVersions: options.generatorVersions ?? {}
|
|
3234
3262
|
} : void 0;
|
|
3235
3263
|
await this.service.committer.commitGeneration("Update SDK", commitOpts);
|
|
3236
3264
|
await this.service.cleanupStaleConflictMarkers();
|
|
@@ -3240,7 +3268,6 @@ var NormalRegenerationFlow = class {
|
|
|
3240
3268
|
flow: "normal-regeneration",
|
|
3241
3269
|
previousGenerationSha,
|
|
3242
3270
|
currentGenerationSha: genRecord.commit_sha,
|
|
3243
|
-
baseBranchHead: options?.baseBranchHead ?? null,
|
|
3244
3271
|
_prepared: {
|
|
3245
3272
|
kind: "active",
|
|
3246
3273
|
flow: "normal-regeneration",
|
|
@@ -3433,68 +3460,6 @@ var ReplayService = class {
|
|
|
3433
3460
|
const prep = await this.prepareReplay(options);
|
|
3434
3461
|
return this.applyPreparedReplay(prep, options);
|
|
3435
3462
|
}
|
|
3436
|
-
/**
|
|
3437
|
-
* Sync the lockfile after a divergent PR was squash-merged.
|
|
3438
|
-
* Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
|
|
3439
|
-
*
|
|
3440
|
-
* After updating the generation record, re-detects customer patches via
|
|
3441
|
-
* tree diff between the generation tag (pure generation tree) and HEAD
|
|
3442
|
-
* (which includes customer customizations after squash merge). This ensures
|
|
3443
|
-
* patches survive the squash merge → regenerate cycle even when the lockfile
|
|
3444
|
-
* was restored from the base branch during conflict PR creation.
|
|
3445
|
-
*/
|
|
3446
|
-
async syncFromDivergentMerge(generationCommitSha, options) {
|
|
3447
|
-
const treeHash = await this.git.getTreeHash(generationCommitSha);
|
|
3448
|
-
const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
|
|
3449
|
-
const record = {
|
|
3450
|
-
commit_sha: generationCommitSha,
|
|
3451
|
-
tree_hash: treeHash,
|
|
3452
|
-
timestamp,
|
|
3453
|
-
cli_version: options?.cliVersion ?? "unknown",
|
|
3454
|
-
generator_versions: options?.generatorVersions ?? {},
|
|
3455
|
-
base_branch_head: options?.baseBranchHead
|
|
3456
|
-
};
|
|
3457
|
-
let resolvedPatches;
|
|
3458
|
-
if (!this.lockManager.exists()) {
|
|
3459
|
-
this.lockManager.initializeInMemory(record);
|
|
3460
|
-
} else {
|
|
3461
|
-
this.lockManager.read();
|
|
3462
|
-
const unresolvedPatches = [
|
|
3463
|
-
...this.lockManager.getUnresolvedPatches(),
|
|
3464
|
-
...this.lockManager.getResolvingPatches()
|
|
3465
|
-
];
|
|
3466
|
-
resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
|
|
3467
|
-
this.lockManager.addGeneration(record);
|
|
3468
|
-
this.lockManager.clearPatches();
|
|
3469
|
-
for (const patch of unresolvedPatches) {
|
|
3470
|
-
this.lockManager.addPatch(patch);
|
|
3471
|
-
}
|
|
3472
|
-
}
|
|
3473
|
-
try {
|
|
3474
|
-
const { patches: redetectedPatches } = await this.detector.detectNewPatches();
|
|
3475
|
-
if (redetectedPatches.length > 0) {
|
|
3476
|
-
const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
|
|
3477
|
-
const currentPatches = this.lockManager.getPatches();
|
|
3478
|
-
for (const patch of currentPatches) {
|
|
3479
|
-
if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
|
|
3480
|
-
this.lockManager.removePatch(patch.id);
|
|
3481
|
-
}
|
|
3482
|
-
}
|
|
3483
|
-
for (const patch of redetectedPatches) {
|
|
3484
|
-
this.lockManager.addPatch(patch);
|
|
3485
|
-
}
|
|
3486
|
-
}
|
|
3487
|
-
} catch (error) {
|
|
3488
|
-
for (const patch of resolvedPatches ?? []) {
|
|
3489
|
-
this.lockManager.addPatch(patch);
|
|
3490
|
-
}
|
|
3491
|
-
this.detector.warnings.push(
|
|
3492
|
-
`Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
|
|
3493
|
-
);
|
|
3494
|
-
}
|
|
3495
|
-
this.lockManager.save();
|
|
3496
|
-
this.detector.warnings.push(...this.lockManager.warnings);
|
|
3497
|
-
}
|
|
3498
3463
|
determineFlow() {
|
|
3499
3464
|
try {
|
|
3500
3465
|
const lock = this.lockManager.read();
|