Skip to content

Commit 11b6300

Browse files
iclantonclaude
andauthored
[rush] Add experiment to omit macOS AppleDouble files from build cache (#5625)
* Add omitAppleDoubleFilesFromBuildCache experiment to filter macOS metadata files from build cache macOS creates AppleDouble (._*) files to store extended attributes on filesystems that don't support them. These files can end up in shared build cache archives, polluting them with platform-specific metadata. This adds a new experiment that, when enabled on macOS, omits ._X files from cache archives when a companion file X exists in the same directory. Co-Authored-By: Claude <noreply@anthropic.com> * Pass omitAppleDoubleFilesFromBuildCache experiment to rush-bridge-cache-plugin Co-Authored-By: Claude <noreply@anthropic.com> * fixup! Add omitAppleDoubleFilesFromBuildCache experiment to filter macOS metadata files from build cache --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a71bcd2 commit 11b6300

10 files changed

Lines changed: 288 additions & 34 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Add a new \"omitAppleDoubleFilesFromBuildCache\" experiment. When enabled, the Rush build cache will omit macOS AppleDouble metadata files (._*) from cache archives when a companion file exists in the same directory. This prevents platform-specific metadata files from polluting the shared build cache. The filtering only applies when running on macOS.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

common/reviews/api/rush-lib.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ export interface IExperimentsJson {
471471
forbidPhantomResolvableNodeModulesFolders?: boolean;
472472
generateProjectImpactGraphDuringRushUpdate?: boolean;
473473
noChmodFieldInTarHeaderNormalization?: boolean;
474+
omitAppleDoubleFilesFromBuildCache?: boolean;
474475
omitImportersFromPreventManualShrinkwrapChanges?: boolean;
475476
printEventHooksOutputToConsole?: boolean;
476477
rushAlerts?: boolean;
@@ -583,6 +584,7 @@ export interface _INpmOptionsJson extends IPackageManagerOptionsJsonBase {
583584
// @internal (undocumented)
584585
export interface _IOperationBuildCacheOptions {
585586
buildCacheConfiguration: BuildCacheConfiguration;
587+
filterAppleDoubleFiles: boolean;
586588
terminal: ITerminal;
587589
}
588590

libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,5 +117,13 @@
117117
* subspace. This is useful for large product groups who work in separate subspaces and generally prefer to consume
118118
* each other's packages via the NPM registry.
119119
*/
120-
/*[LINE "HYPOTHETICAL"]*/ "exemptDecoupledDependenciesBetweenSubspaces": false
120+
/*[LINE "HYPOTHETICAL"]*/ "exemptDecoupledDependenciesBetweenSubspaces": false,
121+
122+
/**
123+
* If true, when running on macOS, Rush will omit AppleDouble files (._*) from build cache archives
124+
* when a companion file exists in the same directory. AppleDouble files are automatically created by
125+
* macOS to store extended attributes on filesystems that don't support them, and should generally not
126+
* be included in the shared build cache.
127+
*/
128+
/*[LINE "HYPOTHETICAL"]*/ "omitAppleDoubleFilesFromBuildCache": true
121129
}

libraries/rush-lib/src/api/ExperimentsConfiguration.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ export interface IExperimentsJson {
128128
* each other's packages via the NPM registry.
129129
*/
130130
exemptDecoupledDependenciesBetweenSubspaces?: boolean;
131+
132+
/**
133+
* If true, when running on macOS, Rush will omit AppleDouble files (`._*`) from build cache archives
134+
* when a companion file exists in the same directory. AppleDouble files are automatically created by
135+
* macOS to store extended attributes on filesystems that don't support them, and should generally not
136+
* be included in the shared build cache.
137+
*/
138+
omitAppleDoubleFilesFromBuildCache?: boolean;
131139
}
132140

133141
const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson);

libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,10 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
508508
.buildCacheWithAllowWarningsInSuccessfulBuild,
509509
buildCacheConfiguration,
510510
cobuildConfiguration,
511-
terminal
511+
terminal,
512+
filterAppleDoubleFiles:
513+
!!this.rushConfiguration.experimentsConfiguration.configuration
514+
.omitAppleDoubleFilesFromBuildCache
512515
}).apply(this.hooks);
513516

514517
if (this._debugBuildCacheIdsParameter.value) {

libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export interface IOperationBuildCacheOptions {
2727
* The terminal to use for logging.
2828
*/
2929
terminal: ITerminal;
30+
/**
31+
* If true, omit AppleDouble (`._*`) files from cache archives when running on macOS
32+
* and a companion file exists in the same directory.
33+
*/
34+
filterAppleDoubleFiles: boolean;
3035
}
3136

3237
/**
@@ -69,6 +74,7 @@ export class OperationBuildCache {
6974
private readonly _cacheWriteEnabled: boolean;
7075
private readonly _projectOutputFolderNames: ReadonlyArray<string>;
7176
private readonly _cacheId: string | undefined;
77+
private readonly _filterAppleDoubleFiles: boolean;
7278

7379
private constructor(cacheId: string | undefined, options: IProjectBuildCacheOptions) {
7480
const {
@@ -79,7 +85,8 @@ export class OperationBuildCache {
7985
cacheWriteEnabled
8086
},
8187
project,
82-
projectOutputFolderNames
88+
projectOutputFolderNames,
89+
filterAppleDoubleFiles
8390
} = options;
8491
this._project = project;
8592
this._localBuildCacheProvider = localCacheProvider;
@@ -88,6 +95,7 @@ export class OperationBuildCache {
8895
this._cacheWriteEnabled = cacheWriteEnabled;
8996
this._projectOutputFolderNames = projectOutputFolderNames || [];
9097
this._cacheId = cacheId;
98+
this._filterAppleDoubleFiles = filterAppleDoubleFiles && process.platform === 'darwin';
9199
}
92100

93101
private static _tryGetTarUtility(terminal: ITerminal): Promise<TarExecutable | undefined> {
@@ -111,17 +119,20 @@ export class OperationBuildCache {
111119
executionResult: IOperationExecutionResult,
112120
options: IOperationBuildCacheOptions
113121
): OperationBuildCache {
122+
const { buildCacheConfiguration, terminal, filterAppleDoubleFiles } = options;
114123
const outputFolders: string[] = [...(executionResult.operation.settings?.outputFolderNames ?? [])];
115124
if (executionResult.metadataFolderPath) {
116125
outputFolders.push(executionResult.metadataFolderPath);
117126
}
127+
118128
const buildCacheOptions: IProjectBuildCacheOptions = {
119-
buildCacheConfiguration: options.buildCacheConfiguration,
120-
terminal: options.terminal,
129+
buildCacheConfiguration,
130+
terminal,
121131
project: executionResult.operation.associatedProject,
122132
phaseName: executionResult.operation.associatedPhase.name,
123133
projectOutputFolderNames: outputFolders,
124-
operationStateHash: executionResult.getStateHash()
134+
operationStateHash: executionResult.getStateHash(),
135+
filterAppleDoubleFiles
125136
};
126137
const cacheId: string | undefined = OperationBuildCache._getCacheId(buildCacheOptions);
127138
return new OperationBuildCache(cacheId, buildCacheOptions);
@@ -341,11 +352,20 @@ export class OperationBuildCache {
341352
const filteredOutputFolderNames: string[] = [];
342353

343354
let hasSymbolicLinks: boolean = false;
355+
const filterAppleDoubleFiles: boolean = this._filterAppleDoubleFiles;
344356

345357
// Adds child directories to the queue, files to the path list, and bails on symlinks
346358
function processChildren(relativePath: string, diskPath: string, children: FolderItem[]): void {
359+
// When filtering AppleDouble files, build a set of sibling names so we can check
360+
// whether a companion file exists for each ._X file.
361+
let childNameSet: Set<string> | undefined;
362+
if (filterAppleDoubleFiles) {
363+
childNameSet = new Set<string>(children.map(({ name }) => name));
364+
}
365+
347366
for (const child of children) {
348-
const childRelativePath: string = `${relativePath}/${child.name}`;
367+
const childName: string = child.name;
368+
const childRelativePath: string = `${relativePath}/${childName}`;
349369
if (child.isSymbolicLink()) {
350370
terminal.writeError(
351371
`Unable to include "${childRelativePath}" in build cache. It is a symbolic link.`
@@ -354,6 +374,15 @@ export class OperationBuildCache {
354374
} else if (child.isDirectory()) {
355375
queue.push([childRelativePath, `${diskPath}/${child.name}`]);
356376
} else {
377+
// Check for macOS AppleDouble files (._X pattern) that have a companion file
378+
if (childNameSet && childName.length > 2 && childName.startsWith('._')) {
379+
const companionName: string = childName.substring(2);
380+
if (childNameSet.has(companionName)) {
381+
terminal.writeVerboseLine(`Omitting AppleDouble file "${childRelativePath}" from build cache.`);
382+
continue;
383+
}
384+
}
385+
357386
outputFilePaths.push(childRelativePath);
358387
}
359388
}

libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4+
import { FileSystem, type FolderItem } from '@rushstack/node-core-library';
45
import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';
56

67
import type { BuildCacheConfiguration } from '../../../api/BuildCacheConfiguration';
@@ -14,6 +15,22 @@ interface ITestOptions {
1415
enabled: boolean;
1516
writeAllowed: boolean;
1617
trackedProjectFiles: string[] | undefined;
18+
filterAppleDoubleFiles: boolean;
19+
}
20+
21+
function createFolderItem(name: string, type: 'file' | 'directory' | 'symlink'): FolderItem {
22+
return {
23+
name,
24+
isSymbolicLink: () => type === 'symlink',
25+
isDirectory: () => type === 'directory',
26+
isFile: () => type === 'file',
27+
isBlockDevice: () => false,
28+
isCharacterDevice: () => false,
29+
isFIFO: () => false,
30+
isSocket: () => false,
31+
parentPath: '',
32+
path: name
33+
} as unknown as FolderItem;
1734
}
1835

1936
describe(OperationBuildCache.name, () => {
@@ -34,13 +51,15 @@ describe(OperationBuildCache.name, () => {
3451
project: {
3552
packageName: 'acme-wizard',
3653
projectRelativeFolder: 'apps/acme-wizard',
54+
projectFolder: '/repo/apps/acme-wizard',
3755
dependencyProjects: []
3856
} as unknown as RushConfigurationProject,
3957
// Value from past tests, for consistency.
4058
// The project build cache is not responsible for calculating this value.
4159
operationStateHash: '1926f30e8ed24cb47be89aea39e7efd70fcda075',
4260
terminal,
43-
phaseName: 'build'
61+
phaseName: 'build',
62+
filterAppleDoubleFiles: !!options.filterAppleDoubleFiles
4463
});
4564

4665
return subject;
@@ -54,4 +73,146 @@ describe(OperationBuildCache.name, () => {
5473
);
5574
});
5675
});
76+
77+
describe('AppleDouble file filtering', () => {
78+
const originalPlatform: NodeJS.Platform = process.platform;
79+
80+
afterEach(() => {
81+
Object.defineProperty(process, 'platform', { value: originalPlatform });
82+
jest.restoreAllMocks();
83+
});
84+
85+
it('omits AppleDouble files with companions when enabled on macOS', async () => {
86+
Object.defineProperty(process, 'platform', { value: 'darwin' });
87+
88+
const subject: OperationBuildCache = prepareSubject({ filterAppleDoubleFiles: true });
89+
90+
jest
91+
.spyOn(FileSystem, 'readFolderItemsAsync')
92+
.mockResolvedValue([
93+
createFolderItem('foo.txt', 'file'),
94+
createFolderItem('._foo.txt', 'file'),
95+
createFolderItem('bar.js', 'file'),
96+
createFolderItem('._bar.js', 'file')
97+
]);
98+
99+
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
100+
const terminal: Terminal = new Terminal(terminalProvider);
101+
102+
const result: { outputFilePaths: string[]; filteredOutputFolderNames: string[] } | undefined =
103+
await subject['_tryCollectPathsToCacheAsync'](terminal);
104+
105+
expect(result).toBeDefined();
106+
expect(result!.outputFilePaths).toEqual(['dist/bar.js', 'dist/foo.txt']);
107+
expect(result!.outputFilePaths).not.toContain('dist/._foo.txt');
108+
expect(result!.outputFilePaths).not.toContain('dist/._bar.js');
109+
});
110+
111+
it('keeps AppleDouble files without companion files', async () => {
112+
Object.defineProperty(process, 'platform', { value: 'darwin' });
113+
114+
const subject: OperationBuildCache = prepareSubject({ filterAppleDoubleFiles: true });
115+
116+
jest
117+
.spyOn(FileSystem, 'readFolderItemsAsync')
118+
.mockResolvedValue([createFolderItem('._orphan.txt', 'file'), createFolderItem('other.js', 'file')]);
119+
120+
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
121+
const terminal: Terminal = new Terminal(terminalProvider);
122+
123+
const result: { outputFilePaths: string[]; filteredOutputFolderNames: string[] } | undefined =
124+
await subject['_tryCollectPathsToCacheAsync'](terminal);
125+
126+
expect(result).toBeDefined();
127+
expect(result!.outputFilePaths).toEqual(['dist/._orphan.txt', 'dist/other.js']);
128+
});
129+
130+
it('does not filter AppleDouble files when the experiment is disabled', async () => {
131+
Object.defineProperty(process, 'platform', { value: 'darwin' });
132+
133+
const subject: OperationBuildCache = prepareSubject({ filterAppleDoubleFiles: false });
134+
135+
jest
136+
.spyOn(FileSystem, 'readFolderItemsAsync')
137+
.mockResolvedValue([createFolderItem('foo.txt', 'file'), createFolderItem('._foo.txt', 'file')]);
138+
139+
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
140+
const terminal: Terminal = new Terminal(terminalProvider);
141+
142+
const result: { outputFilePaths: string[]; filteredOutputFolderNames: string[] } | undefined =
143+
await subject['_tryCollectPathsToCacheAsync'](terminal);
144+
145+
expect(result).toBeDefined();
146+
expect(result!.outputFilePaths).toEqual(['dist/._foo.txt', 'dist/foo.txt']);
147+
});
148+
149+
it('does not filter AppleDouble files on non-macOS platforms', async () => {
150+
Object.defineProperty(process, 'platform', { value: 'win32' });
151+
152+
const subject: OperationBuildCache = prepareSubject({ filterAppleDoubleFiles: true });
153+
154+
jest
155+
.spyOn(FileSystem, 'readFolderItemsAsync')
156+
.mockResolvedValue([createFolderItem('foo.txt', 'file'), createFolderItem('._foo.txt', 'file')]);
157+
158+
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
159+
const terminal: Terminal = new Terminal(terminalProvider);
160+
161+
const result: { outputFilePaths: string[]; filteredOutputFolderNames: string[] } | undefined =
162+
await subject['_tryCollectPathsToCacheAsync'](terminal);
163+
164+
expect(result).toBeDefined();
165+
expect(result!.outputFilePaths).toEqual(['dist/._foo.txt', 'dist/foo.txt']);
166+
});
167+
168+
it('does not filter files named exactly "._"', async () => {
169+
Object.defineProperty(process, 'platform', { value: 'darwin' });
170+
171+
const subject: OperationBuildCache = prepareSubject({ filterAppleDoubleFiles: true });
172+
173+
jest
174+
.spyOn(FileSystem, 'readFolderItemsAsync')
175+
.mockResolvedValue([createFolderItem('._', 'file'), createFolderItem('other.txt', 'file')]);
176+
177+
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
178+
const terminal: Terminal = new Terminal(terminalProvider);
179+
180+
const result: { outputFilePaths: string[]; filteredOutputFolderNames: string[] } | undefined =
181+
await subject['_tryCollectPathsToCacheAsync'](terminal);
182+
183+
expect(result).toBeDefined();
184+
expect(result!.outputFilePaths).toEqual(['dist/._', 'dist/other.txt']);
185+
});
186+
187+
it('filters AppleDouble files in nested directories', async () => {
188+
Object.defineProperty(process, 'platform', { value: 'darwin' });
189+
190+
const subject: OperationBuildCache = prepareSubject({ filterAppleDoubleFiles: true });
191+
192+
// First call returns the top-level dist/ contents with a subdirectory
193+
// Second call returns the subdirectory contents
194+
jest
195+
.spyOn(FileSystem, 'readFolderItemsAsync')
196+
.mockResolvedValueOnce([
197+
createFolderItem('index.js', 'file'),
198+
createFolderItem('._index.js', 'file'),
199+
createFolderItem('sub', 'directory')
200+
])
201+
.mockResolvedValueOnce([
202+
createFolderItem('nested.js', 'file'),
203+
createFolderItem('._nested.js', 'file')
204+
]);
205+
206+
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
207+
const terminal: Terminal = new Terminal(terminalProvider);
208+
209+
const result: { outputFilePaths: string[]; filteredOutputFolderNames: string[] } | undefined =
210+
await subject['_tryCollectPathsToCacheAsync'](terminal);
211+
212+
expect(result).toBeDefined();
213+
expect(result!.outputFilePaths).toEqual(['dist/index.js', 'dist/sub/nested.js']);
214+
expect(result!.outputFilePaths).not.toContain('dist/._index.js');
215+
expect(result!.outputFilePaths).not.toContain('dist/sub/._nested.js');
216+
});
217+
});
57218
});

0 commit comments

Comments
 (0)