Introducing Fern Replay

4 min readTanmay Singh
0:00
0:00

TL;DR: Replay reapplies your SDK customizations on top of every regeneration via a 3-way merge, landing as a [fern-replay] commit alongside the regenerated code. Available across all SDK languages.

Fern-generated SDKs are meant to be extended by adding helpers, custom types, or domain-specific logic directly in the codebase.

Every fern generate overwrites the SDK directory. Generated code has to track the spec, so that's the right default — but it's a wall for anyone who wants to keep a custom retry helper, or wrap a generated client in something idiomatic to their codebase.

Why we built Replay

Until Replay, the only answer was .fernignore — Fern's file-level escape hatch that lets you protect specific paths in your SDK from being overwritten during regeneration. Mark a file as ignored and the generator stops touching it. The trade-off: you own the whole file forever. Every generator fix that should land in it stops at the ignore line.

This doesn't scale: one of our customer teams had 66 files in .fernignore to patch a single type.

Replay is the line-level answer. Fern Replay builds on this idea. Instead of blocking Fern from touching certain files, Fern detects your manual edits and automatically reapplies them with each new generation.

The two now coexist: use .fernignore for full-file ownership, Replay for line-level edits to generated files.

What Replay is

Replay runs in three phases on every fern generate: detect your edits, apply them to the new output, commit the result.

Detect. Replay scans the SDK repo's git history since the last Fern-generated commit. Each of your custom commits in that range becomes a tracked patch, anchored to the generation it was written against.

Apply. For each tracked patch, Replay runs a 3-way merge:the generator's output at the time of your edit, the freshly generated state on disk, and your edited version . Clean merges land silently. Conflicts get reported in the PR body, and you can usefern replay resolve to walk through it locally.

Commit. The applied patches land as a single [fern-replay] commit on top of [fern-generated]. The regeneration arrives as one PR with both commits stacked on main:

git log
* abc123 (HEAD -> main) [fern-replay] Apply customizations
* 789abc [fern-generated] Update SDK
* 234bcd Add helpers on top of User type
* ...

This is the kind of edit Replay was built to preserve:

user-helpers.ts
// Customer commit on top of the generated User type:
 
/** Returns the user's displayName, falling back to "Anonymous User" when unset. */
export function getDisplayNameOrAnonymous(user: User): string {
    const trimmed = user.displayName?.trim();
    return trimmed && trimmed.length > 0 ? trimmed : "Anonymous User";
}
 
/** Returns the user's preferred handle: nickname first, then displayName, then email-prefix. */
export function preferredHandle(user: User): string {
    if (user.nickname && user.nickname.trim().length > 0) return user.nickname.trim();
    if (user.displayName && user.displayName.trim().length > 0) return user.displayName.trim();
    return user.email.split("@")[0] ?? "user";
}

You commit this once. On the next fern generate, the generated User type may pick up new fields, drop a property, or move to a different file. Replay reapplies these helpers on top, anchored on a 3-way merge against the version of User the customer originally wrote against.

The hard parts

A 3-way merge in a loop is easy. The cases below are why Replay isn't a shell script over git apply.

Renames. Fern's generator restructures often: a class moves to a new file, a directory gets renamed, a single client splits into many. Git's rename detection covers most of it. For the cases it can't see, like large rewrites or fully renamed directories, you declare the move explicitly. Either way, the patch's anchor follows the file.

Multi-patch overlap. Two commits from your team touch the same file. A naive 3-way merge applies each patch in isolation, which produces phantom conflicts: the second patch sees an unmodified file underneath it. Replay's applicator carries an inter-patch accumulator: for any file touched by more than one patch, the merge base for patch N becomes the result of all prior patches, not the original generation.

State between regens. Your working tree doesn't sit still between regenerations. Replay reads it before every run and reacts to what it finds. A reverted customization; Replay drops the patch. A further tweak; Replay refreshes the patch from disk before the next run overwrites it. A half-resolved conflict (markers still in the file); Replay skips the refresh rather than corrupt the patch.

Squash merges. If you squash-merge regeneration PRs, the original [fern-generated] commit is no longer an ancestor of main, and a naive git log lastGen..HEAD returns nothing useful. Replay doesn't trust the lockfile's recorded SHA — every run re-derives the anchor by walking first-parent history for the most recent commit it recognizes as a generation (by author and message marker). The scan range falls out from there, with the same per-commit semantics as the linear path.

Missing trees. Shallow CI clones and post-squash GC can leave the recorded generation commit unreachable. The lockfile keeps tree hashes alongside each patch, so Replay can detect against the tree directly — no commit walk required. The customer's only signal is that the regeneration still worked.

.fernignore still belongs to you. Replay tracks edits to them (so renames carry the customization) but never drops a patch on the assumption that the generator now produces the file. That distinction keeps us from accidentally collapsing files you've explicitly told us are yours. The two tools coexist: .fernignore for full-file ownership, Replay for line-level edits to everything else.

Built alongside our beta customers

Replay has been running in production since earlier this year across Frame.io and Smallest AI. Auth0 came online last week.

Each partner surfaced a different class of edge case. Squash-merged regeneration PRs forced the non-linear-history detection path. Long-tail .fernignore patterns drove the migration tooling: entries that no longer matched any files, files the generator had since renamed, full-replacement diffs. Multi-developer customizations on the same file forced the inter-patch accumulator. None of these were rare events. They were patterns we hadn't planned for until we shipped to repos with real edits, real branches, and real long-running history.

Replay lets us ship custom methods on top of our generated SDKs without losing them on every regeneration which means we can address specific customer needs without the maintenance costs we would have absorbed otherwise.

Charlie Anderson · Head of Partnerships at Frame.io

Get started today

Replay is generally available today. Read the docs to get started.

New to Fern? Get in touch to start generating SDKs — Replay is included out of the box.

Tanmay Singh