Skip to content

Commit e53c162

Browse files
committed
feat(fmt): support custom cache directories
1 parent fe9c028 commit e53c162

11 files changed

Lines changed: 146 additions & 5 deletions

File tree

packages/rstack/src/fmt/cli.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts';
1313

1414
interface ParsedFmtCLIArgs {
1515
cache: boolean;
16+
cacheLocation?: string;
1617
mode: FmtMode;
1718
patterns: string[];
1819
ignorePaths: string[];
@@ -39,6 +40,7 @@ ${color.cyan('Options')}:
3940
--ignore-path <path> Path to an additional ignore file (repeatable)
4041
-u, --ignore-unknown Ignore unknown files
4142
--no-cache Disable the formatting cache
43+
--cache-location <path> Path to the formatting cache directory
4244
--no-error-on-unmatched-pattern Do not error when no files match
4345
--with-node-modules Process files inside node_modules
4446
--parallel-workers <count> Number of parallel workers
@@ -68,6 +70,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
6870
'ignore-path': { type: 'string', multiple: true },
6971
'ignore-unknown': { type: 'boolean', short: 'u' },
7072
'no-cache': { type: 'boolean' },
73+
'cache-location': { type: 'string' },
7174
'no-error-on-unmatched-pattern': { type: 'boolean' },
7275
'with-node-modules': { type: 'boolean' },
7376
'parallel-workers': { type: 'string' },
@@ -88,6 +91,11 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
8891

8992
const mode = check ? 'check' : listDifferent ? 'list-different' : 'write';
9093
const cache = !(values.noCache ?? false);
94+
const cacheLocation = cache ? values.cacheLocation : undefined;
95+
if (cacheLocation === '') {
96+
throw new Error('The --cache-location option requires a path.');
97+
}
98+
9199
const ignorePaths = values.ignorePath ?? [];
92100
const ignoreUnknown = values.ignoreUnknown ?? false;
93101
const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false;
@@ -111,6 +119,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
111119

112120
return {
113121
cache,
122+
cacheLocation,
114123
mode,
115124
patterns: positionals,
116125
ignorePaths,
@@ -247,6 +256,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
247256
try {
248257
const {
249258
cache,
259+
cacheLocation,
250260
help,
251261
ignorePaths,
252262
ignoreUnknown,
@@ -277,11 +287,13 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
277287
return;
278288
}
279289

290+
const cacheDirPath = cacheLocation ? path.resolve(cwd, cacheLocation) : undefined;
280291
const config = await loadFmtConfig(cwd);
281292
const files = await discoverFmtFiles({
282293
cwd,
283294
patterns,
284295
config,
296+
excludedDirPath: cacheDirPath,
285297
ignorePaths,
286298
withNodeModules,
287299
});
@@ -297,7 +309,12 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
297309
}
298310

299311
let cacheContext;
300-
if (cache) {
312+
if (cacheDirPath) {
313+
cacheContext = {
314+
filePath: path.join(cacheDirPath, fmtCacheFileName),
315+
rootPath: config.rootPath,
316+
};
317+
} else if (cache) {
301318
const cacheDir = await ensureProjectCacheDir(config.rootPath);
302319
if (cacheDir.status === 'available') {
303320
cacheContext = {

packages/rstack/src/fmt/discovery.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from 'node:path';
12
import { createFmtOptionsResolver, type FmtOptionsResolver } from './config.ts';
23
import { discoverFmtPaths } from './discoverPaths.ts';
34
import { createIgnoreMatcher } from './ignore.ts';
@@ -11,26 +12,37 @@ const createFileRequest = (
1112
options: resolveOptions(filePath),
1213
});
1314

15+
const createDirMatcher = (dirPath: string): ((filePath: string) => boolean) => {
16+
const prefix = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`;
17+
return (filePath) => filePath === dirPath || filePath.startsWith(prefix);
18+
};
19+
1420
/** Discovers worker-ready files without automatically reading Prettier config or ignore files. */
1521
const discoverFmtFiles = async ({
1622
cwd,
23+
excludedDirPath,
1724
patterns,
1825
ignorePaths,
1926
withNodeModules,
2027
config,
2128
}: DiscoverFmtFilesOptions): Promise<FmtFileRequest[]> => {
2229
const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths });
30+
const isExcluded = excludedDirPath ? createDirMatcher(excludedDirPath) : undefined;
31+
const shouldIgnore = isExcluded
32+
? (filePath: string, isDirectory = false) =>
33+
isExcluded(filePath) || isIgnored(filePath, isDirectory)
34+
: isIgnored;
2335
const candidates = await discoverFmtPaths({
2436
cwd,
2537
patterns,
2638
withNodeModules,
27-
isIgnored,
39+
isIgnored: shouldIgnore,
2840
});
2941
if (candidates.length === 0) {
3042
return [];
3143
}
3244

33-
const filePaths = candidates.filter((filePath) => !isIgnored(filePath));
45+
const filePaths = candidates.filter((filePath) => !shouldIgnore(filePath));
3446
const resolveOptions = createFmtOptionsResolver(config);
3547
const files = filePaths.map((filePath) => createFileRequest(filePath, resolveOptions));
3648
if (!files.some((file) => file.options.plugins?.length)) {

packages/rstack/src/fmt/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ interface ResolvedFmtConfig {
5555
interface DiscoverFmtFilesOptions {
5656
/** Absolute directory used to resolve input paths. */
5757
cwd: string;
58+
/** Absolute directory to exclude from formatting. */
59+
excludedDirPath?: string;
5860
/** Files, directories, and positive or negative globs. Defaults to the current directory. */
5961
patterns?: string[];
6062
/** Ignore files resolved from `cwd`; each file's patterns are relative to its own directory. */

packages/rstack/tests/cli/fmt/index.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,18 @@ test.each([
187187

188188
test('--no-cache bypasses cache reads and writes', () => {
189189
writeProjectFile('index.ts', 'const value=1');
190-
191-
const first = runFmt(['--no-cache', 'index.ts']);
190+
writeProjectFile('custom-cache/v1.json', '{"value":true}');
191+
192+
const first = runFmt([
193+
'--no-cache',
194+
'--cache-location',
195+
'custom-cache',
196+
'index.ts',
197+
'custom-cache/v1.json',
198+
]);
192199

193200
expect(first.status).toBe(0);
201+
expect(readProjectFile('custom-cache/v1.json')).toBe('{ "value": true }\n');
194202
expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false);
195203

196204
writeProjectFile('.rstack/cache/fmt-v1.json', 'stale');
@@ -203,6 +211,38 @@ test('--no-cache bypasses cache reads and writes', () => {
203211
expect(existsSync(path.join(projectPath, '.rstack/cache/.gitignore'))).toBe(false);
204212
});
205213

214+
test.each(['relative', 'absolute'] as const)('uses a %s custom cache location', (kind) => {
215+
const cacheDir = path.join(projectPath, 'custom-cache');
216+
const cacheLocation = kind === 'relative' ? path.relative(projectPath, cacheDir) : cacheDir;
217+
const cachePath = path.join(cacheDir, 'v1.json');
218+
writeProjectFile('index.ts', 'const value = 1;\n');
219+
220+
const result = runFmt(['--cache-location', cacheLocation, 'index.ts']);
221+
222+
expect(result.status).toBe(0);
223+
expect(JSON.parse(readFileSync(cachePath, 'utf8'))).toMatchObject({
224+
version: 1,
225+
files: {
226+
'index.ts': [expect.any(String), expect.any(String), 'clean'],
227+
},
228+
});
229+
expect(existsSync(path.join(projectPath, 'custom-cache/.gitignore'))).toBe(false);
230+
expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false);
231+
});
232+
233+
test('excludes the custom cache directory from formatting', () => {
234+
const cacheLocation = 'custom-cache';
235+
writeProjectFile('index.ts', 'const value = 1;\n');
236+
writeProjectFile('custom-cache/nested/ignored.ts', 'const value=2');
237+
expect(runFmt(['--cache-location', cacheLocation, 'index.ts']).status).toBe(0);
238+
239+
const result = runFmt(['--cache-location', cacheLocation, '.']);
240+
241+
expect(result.status).toBe(0);
242+
expectWriteSummary(result.stdout, 2, 0);
243+
expect(readProjectFile('custom-cache/nested/ignored.ts')).toBe('const value=2');
244+
});
245+
206246
test('uses an explicit config root cache from a subdirectory', () => {
207247
const appPath = path.join(projectPath, 'packages/app');
208248
writeProjectFile('packages/app/index.ts', 'const value=1');

packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Options:
1313
--ignore-path <path> Path to an additional ignore file (repeatable)
1414
-u, --ignore-unknown Ignore unknown files
1515
--no-cache Disable the formatting cache
16+
--cache-location <path> Path to the formatting cache directory
1617
--no-error-on-unmatched-pattern Do not error when no files match
1718
--with-node-modules Process files inside node_modules
1819
--parallel-workers <count> Number of parallel workers

packages/rstack/tests/fmt/cli.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,23 @@ test('parses --no-cache', () => {
127127
expect(parseFmtCLIArgs(['--no-cache']).cache).toBe(false);
128128
});
129129

130+
test('parses --cache-location', () => {
131+
expect(parseFmtCLIArgs(['--cache-location', '.cache/fmt']).cacheLocation).toBe('.cache/fmt');
132+
});
133+
134+
test('--no-cache ignores --cache-location', () => {
135+
expect(parseFmtCLIArgs(['--no-cache', '--cache-location='])).toMatchObject({
136+
cache: false,
137+
cacheLocation: undefined,
138+
});
139+
});
140+
141+
test('rejects an empty cache location', () => {
142+
expect(() => parseFmtCLIArgs(['--cache-location='])).toThrow(
143+
'The --cache-location option requires a path.',
144+
);
145+
});
146+
130147
test('parses --with-node-modules', () => {
131148
expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true);
132149
});

packages/rstack/tests/fmt/discovery.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,30 @@ test('excludes .rstack from discovery', async () => {
6060
});
6161
});
6262

63+
test('excludes a custom cache directory', async () => {
64+
await withTempProject(async (rootPath) => {
65+
const cacheDir = path.join(rootPath, 'custom-cache');
66+
const cacheFile = writeProjectFile(rootPath, 'custom-cache/v1.json', '{}');
67+
writeProjectFile(rootPath, 'custom-cache/nested/ignored.ts');
68+
writeProjectFile(rootPath, 'index.ts');
69+
70+
const discoveredFiles = await discoverFmtFiles({
71+
cwd: rootPath,
72+
excludedDirPath: cacheDir,
73+
config: normalizeFmtConfig(undefined, rootPath),
74+
});
75+
const explicitFile = await discoverFmtFiles({
76+
cwd: rootPath,
77+
excludedDirPath: cacheDir,
78+
patterns: [cacheFile],
79+
config: normalizeFmtConfig(undefined, rootPath),
80+
});
81+
82+
expect(relativePaths(rootPath, discoveredFiles)).toEqual(['index.ts']);
83+
expect(explicitFile).toEqual([]);
84+
});
85+
});
86+
6387
test('keeps files re-included by a CLI ignore file during directory traversal', async () => {
6488
await withTempProject(async (rootPath) => {
6589
writeProjectFile(rootPath, '.prettierignore', 'generated/*\n!generated/keep.ts\n');

website/docs/en/guide/cli/fmt.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ Without this option, `rs fmt` stores cache data in `.rstack/cache/fmt` under the
133133

134134
See [Cache](../formatting#cache) for cache behavior and cleanup guidance.
135135

136+
### `--cache-location <path>`
137+
138+
Store the persistent cache in a custom directory:
139+
140+
```bash
141+
rs fmt --cache-location .cache/rs-fmt
142+
```
143+
144+
Relative paths are resolved from the current working directory, while absolute paths are used as-is. The directory is created as needed and excluded from file discovery. Unlike the default cache location, a custom directory does not receive an automatic `.gitignore`; exclude it from version control or manage it through your CI cache configuration.
145+
146+
When both options are provided, `--no-cache` takes precedence and the custom directory is not excluded from file discovery.
147+
136148
### `--no-error-on-unmatched-pattern`
137149

138150
Exit successfully without diagnostics when no files match the provided paths or globs, including when all matching files are ignored:

website/docs/en/guide/formatting.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ define.fmt({
180180

181181
The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache.
182182

183+
Use [`--cache-location <path>`](./cli/fmt#--cache-location-path) to store the cache in a different directory. Relative paths are resolved from the current working directory. Custom directories are excluded from file discovery but are not automatically ignored by Git.
184+
183185
Use [`--no-cache`](./cli/fmt#--no-cache) to run without reading, creating, or updating the cache:
184186

185187
```bash

website/docs/zh/guide/cli/fmt.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ rs fmt --no-cache
133133

134134
缓存行为和清理方式请参考[缓存](../formatting#cache)
135135

136+
### `--cache-location <path>`
137+
138+
将持久化缓存保存到自定义目录:
139+
140+
```bash
141+
rs fmt --cache-location .cache/rs-fmt
142+
```
143+
144+
相对路径基于当前工作目录解析,绝对路径则原样使用。目录会在需要时自动创建,并从文件发现中排除。与默认缓存位置不同,自定义目录不会自动生成 `.gitignore`;请将其排除在版本控制之外,或通过 CI 缓存配置进行管理。
145+
146+
同时使用两个选项时,优先使用 `--no-cache`,且不会从文件发现中排除自定义目录。
147+
136148
### `--no-error-on-unmatched-pattern`
137149

138150
如果传入的路径或 glob 没有匹配任何文件(包括所有匹配文件均被忽略的情况),则不输出诊断信息并成功退出:

0 commit comments

Comments
 (0)