Skip to content

Commit 46ca61a

Browse files
authored
fix(cli): ship .gitattributes entries from fern replay init to Fiddle (#15504)
`replayInit()` writes `.gitattributes` into a temp clone that is then discarded — the actual PR is built server-side by Fiddle, but the wire body never carried the entries, so generated PRs were missing the `linguist-generated=true` marker (e.g. auth0/auth0-python#833). Adds a `gitattributesEntries` field to `ReplayInitResult` and the Fiddle submit body (both v1 inline and v2 `submitReplayInit`). Drops the unused `replayYmlContent` field and the temp-clone writes that went nowhere. Requires a matching Fiddle change to consume the new field; until that lands the field is silently ignored (no regression).
1 parent 7dd8866 commit 46ca61a

8 files changed

Lines changed: 49 additions & 24 deletions

File tree

packages/cli/cli-v2/src/commands/replay/init/__test__/command.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ function makeInitResult(overrides: Partial<ReplayInitResult> = {}): ReplayInitRe
7575
bootstrap: makeBootstrap(null),
7676
lockfileContent: undefined,
7777
fernignoreEntries: [],
78+
gitattributesEntries: [".fern/replay.lock linguist-generated=true"],
7879
prBody: "",
7980
...overrides
8081
};
@@ -133,17 +134,23 @@ describe("InitCommand", () => {
133134
})
134135
);
135136

136-
global.fetch = vi.fn().mockResolvedValue({
137+
const fetchMock = vi.fn().mockResolvedValue({
137138
ok: true,
138139
json: vi.fn().mockResolvedValue({ prUrl: "https://github.com/owner/repo/pull/1" })
139-
}) as unknown as typeof fetch;
140+
});
141+
global.fetch = fetchMock as unknown as typeof fetch;
140142

141143
const context = createMockContext();
142144
await cmd.handle(context, baseArgs());
143145

144146
expect(context.stderr.info).toHaveBeenCalledWith(
145147
expect.stringContaining("https://github.com/owner/repo/pull/1")
146148
);
149+
150+
expect(fetchMock).toHaveBeenCalledTimes(1);
151+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
152+
const body = JSON.parse(init.body as string) as Record<string, unknown>;
153+
expect(body.gitattributesEntries).toEqual([".fern/replay.lock linguist-generated=true"]);
147154
});
148155

149156
it("throws CliError when response is missing prUrl", async () => {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json
2+
3+
- summary: |
4+
Send `gitattributesEntries` to Fiddle on `fern replay init` so the
5+
generated PR marks `.fern/replay.lock` as `linguist-generated=true`.
6+
Previously the entries were written into a temp clone that was discarded
7+
and never reached the server. Requires a matching Fiddle server change
8+
to consume the new field.
9+
type: fix

packages/cli/cli/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2965,6 +2965,7 @@ function addReplayInitCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContex
29652965
repo,
29662966
lockfileContents: result.lockfileContent,
29672967
fernignoreEntries: result.fernignoreEntries,
2968+
gitattributesEntries: result.gitattributesEntries,
29682969
prBody: result.prBody
29692970
})
29702971
});

packages/generator-cli/src/__test__/replay-pure.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
logReplaySummary,
1010
patchDescription
1111
} from "../pipeline/replay-summary";
12-
import { ensureReplayFernignoreEntries, REPLAY_FERNIGNORE_ENTRIES } from "../replay/fernignore";
12+
import { ensureReplayFernignoreEntries, GITATTRIBUTES_ENTRIES, REPLAY_FERNIGNORE_ENTRIES } from "../replay/fernignore";
1313

1414
// ---------------------------------------------------------------------------
1515
// formatConflictReason
@@ -438,6 +438,10 @@ describe("fernignore", () => {
438438
expect(REPLAY_FERNIGNORE_ENTRIES.length).toBeGreaterThanOrEqual(3);
439439
});
440440

441+
it("GITATTRIBUTES_ENTRIES marks the replay lockfile as linguist-generated", () => {
442+
expect(GITATTRIBUTES_ENTRIES).toContain(".fern/replay.lock linguist-generated=true");
443+
});
444+
441445
it("creates .fernignore with entries when no file exists, returns true", async () => {
442446
const dir = await tmp.dir({ unsafeCleanup: true });
443447
try {

packages/generator-cli/src/replay/fernignore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { join } from "path";
44

55
export const REPLAY_FERNIGNORE_ENTRIES = [".fern/replay.lock", ".fern/replay.yml", ".gitattributes"];
66

7-
const GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
7+
export const GITATTRIBUTES_ENTRIES = [".fern/replay.lock linguist-generated=true"];
88

99
export async function ensureReplayFernignoreEntries(outputDir: string): Promise<boolean> {
1010
const fernignorePath = join(outputDir, ".fernignore");

packages/generator-cli/src/replay/replay-init.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@ import { type BootstrapResult, bootstrap } from "@fern-api/replay";
33
import { existsSync, readFileSync } from "fs";
44
import { join } from "path";
55
import tmp from "tmp-promise";
6-
import {
7-
ensureGitattributesEntriesSync,
8-
ensureReplayFernignoreEntriesSync,
9-
REPLAY_FERNIGNORE_ENTRIES
10-
} from "./fernignore";
6+
import { GITATTRIBUTES_ENTRIES, REPLAY_FERNIGNORE_ENTRIES } from "./fernignore";
117

128
export interface ReplayInitParams {
139
/** GitHub repo URI (e.g., "fern-demo/fern-replay-testbed-java-sdk") */
@@ -29,10 +25,10 @@ export interface ReplayInitResult {
2925
bootstrap: BootstrapResult;
3026
/** Raw lockfile YAML content, present when bootstrap succeeded and not dry-run */
3127
lockfileContent?: string;
32-
/** Raw replay.yml content, present only when fernignore migration created it */
33-
replayYmlContent?: string;
3428
/** Fernignore entries that the server should ensure exist */
3529
fernignoreEntries: string[];
30+
/** Gitattributes entries the server should ensure exist (e.g. linguist-generated markers) */
31+
gitattributesEntries: string[];
3632
/** Generated PR body markdown for the server to use */
3733
prBody?: string;
3834
}
@@ -46,8 +42,8 @@ export interface ReplayInitResult {
4642
* Flow:
4743
* 1. Clone the SDK repo (read-only)
4844
* 2. Run bootstrap() to scan history and create lockfile
49-
* 3. Ensure .fernignore has replay entries
50-
* 4. Read lockfile content from disk and return it
45+
* 3. Read lockfile content from disk
46+
* 4. Return lockfile + fernignore/gitattributes entries for Fiddle to apply server-side
5147
*/
5248
export async function replayInit(params: ReplayInitParams): Promise<ReplayInitResult> {
5349
const { githubRepo, token, dryRun } = params;
@@ -71,29 +67,22 @@ export async function replayInit(params: ReplayInitParams): Promise<ReplayInitRe
7167
});
7268

7369
if (!bootstrapResult.generationCommit) {
74-
return { bootstrap: bootstrapResult, fernignoreEntries: [] };
70+
return { bootstrap: bootstrapResult, fernignoreEntries: [], gitattributesEntries: [] };
7571
}
7672

7773
if (dryRun) {
78-
return { bootstrap: bootstrapResult, fernignoreEntries: [] };
74+
return { bootstrap: bootstrapResult, fernignoreEntries: [], gitattributesEntries: [] };
7975
}
8076

81-
// 3. Ensure .fernignore has replay entries and .gitattributes marks lockfile as generated
82-
ensureReplayFernignoreEntriesSync(repoPath);
83-
ensureGitattributesEntriesSync(repoPath);
84-
85-
// 4. Read lockfile content from disk
77+
// 3. Read lockfile content from disk
8678
const lockfilePath = join(repoPath, ".fern", "replay.lock");
8779
const lockfileContent = existsSync(lockfilePath) ? readFileSync(lockfilePath, "utf-8") : undefined;
8880

89-
const replayYmlPath = join(repoPath, ".fern", "replay.yml");
90-
const replayYmlContent = existsSync(replayYmlPath) ? readFileSync(replayYmlPath, "utf-8") : undefined;
91-
9281
return {
9382
bootstrap: bootstrapResult,
9483
lockfileContent,
95-
replayYmlContent,
9684
fernignoreEntries: REPLAY_FERNIGNORE_ENTRIES,
85+
gitattributesEntries: GITATTRIBUTES_ENTRIES,
9786
prBody: buildPrBody(bootstrapResult)
9887
};
9988
}

packages/generator-cli/src/replay/replay-submit-init.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export async function submitReplayInit(params: SubmitReplayInitParams): Promise<
4242
repo,
4343
lockfileContents: initResult.lockfileContent,
4444
fernignoreEntries: initResult.fernignoreEntries,
45+
gitattributesEntries: initResult.gitattributesEntries,
4546
prBody: initResult.prBody
4647
})
4748
});

packages/generator-cli/versions.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
11
# yaml-language-server: $schema=../../versions-yml.schema.json
2+
- changelogEntry:
3+
- summary: |
4+
Send `gitattributesEntries` from `replayInit()` so Fiddle can mark
5+
`.fern/replay.lock` as `linguist-generated=true` in the PR it builds.
6+
Previously the local CLI wrote `.gitattributes` into a temp clone that
7+
was discarded; the wire body never carried the entries, so generated
8+
`fern replay init` PRs were missing the `.gitattributes` change. The
9+
Fiddle server must consume this new field for the fix to take effect.
10+
Also drops the unused `replayYmlContent` field and the temp-clone
11+
writes that went nowhere.
12+
type: fix
13+
createdAt: "2026-04-28"
14+
version: 0.9.19
15+
216
- changelogEntry:
317
- summary: |
418
Stop pulling `@boundaryml/baml` into the static dependency graph of the

0 commit comments

Comments
 (0)