Skip to content

Commit 1534338

Browse files
committed
fix(codegen): prune stale generated files
1 parent 022f600 commit 1534338

3 files changed

Lines changed: 125 additions & 65 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as fs from 'node:fs';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
4+
5+
import { writeGeneratedFiles } from '../../core/output';
6+
7+
describe('writeGeneratedFiles', () => {
8+
let tempDir: string;
9+
10+
beforeEach(() => {
11+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-write-test-'));
12+
});
13+
14+
afterEach(() => {
15+
fs.rmSync(tempDir, { recursive: true, force: true });
16+
});
17+
18+
it('removes stale TypeScript files when pruneStaleFiles is enabled', async () => {
19+
const staleRoot = path.join(tempDir, 'stale.ts');
20+
const staleNested = path.join(tempDir, 'nested', 'old.ts');
21+
fs.mkdirSync(path.dirname(staleNested), { recursive: true });
22+
fs.writeFileSync(staleRoot, 'export const stale = true;\n');
23+
fs.writeFileSync(staleNested, 'export const old = true;\n');
24+
25+
const result = await writeGeneratedFiles(
26+
[{ path: 'nested/new.ts', content: 'export const fresh = true;\n' }],
27+
tempDir,
28+
[],
29+
{
30+
showProgress: false,
31+
formatFiles: false,
32+
pruneStaleFiles: true,
33+
},
34+
);
35+
36+
expect(result.success).toBe(true);
37+
expect(fs.existsSync(staleRoot)).toBe(false);
38+
expect(fs.existsSync(staleNested)).toBe(false);
39+
expect(fs.existsSync(path.join(tempDir, 'nested', 'new.ts'))).toBe(true);
40+
});
41+
42+
it('keeps existing files when pruneStaleFiles is disabled', async () => {
43+
const staleRoot = path.join(tempDir, 'stale.ts');
44+
fs.writeFileSync(staleRoot, 'export const stale = true;\n');
45+
46+
const result = await writeGeneratedFiles(
47+
[{ path: 'fresh.ts', content: 'export const fresh = true;\n' }],
48+
tempDir,
49+
[],
50+
{
51+
showProgress: false,
52+
formatFiles: false,
53+
pruneStaleFiles: false,
54+
},
55+
);
56+
57+
expect(result.success).toBe(true);
58+
expect(fs.existsSync(staleRoot)).toBe(true);
59+
expect(fs.existsSync(path.join(tempDir, 'fresh.ts'))).toBe(true);
60+
});
61+
});

graphql/codegen/src/core/generate.ts

Lines changed: 35 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This is the primary entry point for programmatic usage.
55
* The CLI is a thin wrapper around this function.
66
*/
7-
import path from 'path';
7+
import path from 'node:path';
88

99
import type { GraphQLSDKConfigTarget } from '../types/config';
1010
import { getConfigOptions } from '../types/config';
@@ -79,12 +79,12 @@ export async function generate(
7979
endpoint: config.endpoint || undefined,
8080
schemaFile: config.schemaFile || undefined,
8181
db: config.db,
82-
authorization: options.authorization || config.headers?.['Authorization'],
82+
authorization: options.authorization || config.headers?.Authorization,
8383
headers: config.headers,
8484
});
8585

8686
// Run pipeline
87-
let pipelineResult;
87+
let pipelineResult: Awaited<ReturnType<typeof runCodegenPipeline>>;
8888
try {
8989
console.log(`Fetching schema from ${source.describe()}...`);
9090
pipelineResult = await runCodegenPipeline({
@@ -115,6 +115,7 @@ export async function generate(
115115

116116
const allFilesWritten: string[] = [];
117117
const bothEnabled = runReactQuery && runOrm;
118+
const filesToWrite: Array<{ path: string; content: string }> = [];
118119

119120
// Generate shared types when both are enabled
120121
if (bothEnabled) {
@@ -128,28 +129,11 @@ export async function generate(
128129
},
129130
config,
130131
});
131-
132-
if (!options.dryRun) {
133-
const writeResult = await writeGeneratedFiles(
134-
sharedResult.files,
135-
outputRoot,
136-
[],
137-
);
138-
if (!writeResult.success) {
139-
return {
140-
success: false,
141-
message: `Failed to write shared types: ${writeResult.errors?.join(', ')}`,
142-
output: outputRoot,
143-
errors: writeResult.errors,
144-
};
145-
}
146-
allFilesWritten.push(...(writeResult.filesWritten ?? []));
147-
}
132+
filesToWrite.push(...sharedResult.files);
148133
}
149134

150135
// Generate React Query hooks
151136
if (runReactQuery) {
152-
const hooksDir = path.join(outputRoot, 'hooks');
153137
console.log('Generating React Query hooks...');
154138
const { files } = generateReactQueryFiles({
155139
tables,
@@ -161,27 +145,16 @@ export async function generate(
161145
config,
162146
sharedTypesPath: bothEnabled ? '..' : undefined,
163147
});
164-
165-
if (!options.dryRun) {
166-
const writeResult = await writeGeneratedFiles(files, hooksDir, [
167-
'queries',
168-
'mutations',
169-
]);
170-
if (!writeResult.success) {
171-
return {
172-
success: false,
173-
message: `Failed to write React Query hooks: ${writeResult.errors?.join(', ')}`,
174-
output: outputRoot,
175-
errors: writeResult.errors,
176-
};
177-
}
178-
allFilesWritten.push(...(writeResult.filesWritten ?? []));
179-
}
148+
filesToWrite.push(
149+
...files.map((file) => ({
150+
...file,
151+
path: path.posix.join('hooks', file.path),
152+
})),
153+
);
180154
}
181155

182156
// Generate ORM client
183157
if (runOrm) {
184-
const ormDir = path.join(outputRoot, 'orm');
185158
console.log('Generating ORM client...');
186159
const { files } = generateOrmFiles({
187160
tables,
@@ -193,38 +166,36 @@ export async function generate(
193166
config,
194167
sharedTypesPath: bothEnabled ? '..' : undefined,
195168
});
196-
197-
if (!options.dryRun) {
198-
const writeResult = await writeGeneratedFiles(files, ormDir, [
199-
'models',
200-
'query',
201-
'mutation',
202-
]);
203-
if (!writeResult.success) {
204-
return {
205-
success: false,
206-
message: `Failed to write ORM client: ${writeResult.errors?.join(', ')}`,
207-
output: outputRoot,
208-
errors: writeResult.errors,
209-
};
210-
}
211-
allFilesWritten.push(...(writeResult.filesWritten ?? []));
212-
}
169+
filesToWrite.push(
170+
...files.map((file) => ({
171+
...file,
172+
path: path.posix.join('orm', file.path),
173+
})),
174+
);
213175
}
214176

215177
// Generate barrel file at output root
216178
// This re-exports from the appropriate subdirectories based on which generators are enabled
179+
const barrelContent = generateRootBarrel({
180+
hasTypes: bothEnabled,
181+
hasHooks: runReactQuery,
182+
hasOrm: runOrm,
183+
});
184+
filesToWrite.push({ path: 'index.ts', content: barrelContent });
185+
217186
if (!options.dryRun) {
218-
const barrelContent = generateRootBarrel({
219-
hasTypes: bothEnabled,
220-
hasHooks: runReactQuery,
221-
hasOrm: runOrm,
187+
const writeResult = await writeGeneratedFiles(filesToWrite, outputRoot, [], {
188+
pruneStaleFiles: true,
222189
});
223-
await writeGeneratedFiles(
224-
[{ path: 'index.ts', content: barrelContent }],
225-
outputRoot,
226-
[],
227-
);
190+
if (!writeResult.success) {
191+
return {
192+
success: false,
193+
message: `Failed to write generated files: ${writeResult.errors?.join(', ')}`,
194+
output: outputRoot,
195+
errors: writeResult.errors,
196+
};
197+
}
198+
allFilesWritten.push(...(writeResult.filesWritten ?? []));
228199
}
229200

230201
const generators = [runReactQuery && 'React Query', runOrm && 'ORM']

graphql/codegen/src/core/output/writer.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type { GeneratedFile };
1717
export interface WriteResult {
1818
success: boolean;
1919
filesWritten?: string[];
20+
filesRemoved?: string[];
2021
errors?: string[];
2122
}
2223

@@ -28,6 +29,8 @@ export interface WriteOptions {
2829
showProgress?: boolean;
2930
/** Format files with oxfmt after writing (default: true) */
3031
formatFiles?: boolean;
32+
/** Remove stale .ts files in outputDir that are not in current file list (default: false) */
33+
pruneStaleFiles?: boolean;
3134
}
3235

3336
type OxfmtFormatFn = (
@@ -85,9 +88,14 @@ export async function writeGeneratedFiles(
8588
subdirs: string[],
8689
options: WriteOptions = {},
8790
): Promise<WriteResult> {
88-
const { showProgress = true, formatFiles = true } = options;
91+
const {
92+
showProgress = true,
93+
formatFiles = true,
94+
pruneStaleFiles = false,
95+
} = options;
8996
const errors: string[] = [];
9097
const written: string[] = [];
98+
const removed: string[] = [];
9199
const total = files.length;
92100
const isTTY = process.stdout.isTTY;
93101

@@ -117,6 +125,25 @@ export async function writeGeneratedFiles(
117125
return { success: false, errors };
118126
}
119127

128+
if (pruneStaleFiles) {
129+
const expectedFiles = new Set(
130+
files.map((file) => path.resolve(outputDir, file.path)),
131+
);
132+
const existingTsFiles = findTsFiles(outputDir);
133+
134+
for (const existingFile of existingTsFiles) {
135+
const absolutePath = path.resolve(existingFile);
136+
if (expectedFiles.has(absolutePath)) continue;
137+
try {
138+
fs.rmSync(absolutePath, { force: true });
139+
removed.push(absolutePath);
140+
} catch (err) {
141+
const message = err instanceof Error ? err.message : 'Unknown error';
142+
errors.push(`Failed to remove stale file ${absolutePath}: ${message}`);
143+
}
144+
}
145+
}
146+
120147
// Get oxfmt format function if formatting is enabled
121148
const formatFn = formatFiles ? await getOxfmtFormat() : null;
122149
if (formatFiles && !formatFn && showProgress) {
@@ -171,6 +198,7 @@ export async function writeGeneratedFiles(
171198
return {
172199
success: errors.length === 0,
173200
filesWritten: written,
201+
filesRemoved: removed,
174202
errors: errors.length > 0 ? errors : undefined,
175203
};
176204
}

0 commit comments

Comments
 (0)