diff --git a/packages/stim-cli/src/__tests__/worktree-warm.test.ts b/packages/stim-cli/src/__tests__/worktree-warm.test.ts index 00785ec8..a7a03ab9 100644 --- a/packages/stim-cli/src/__tests__/worktree-warm.test.ts +++ b/packages/stim-cli/src/__tests__/worktree-warm.test.ts @@ -488,6 +488,27 @@ test('warm copies literal ignored filenames and skips excluded derived data', () expect(existsSync(join(target, 'node_modules/pkg/.DerivedData'))).toBe(false); }); +test.each(['android/build/', 'apps/mobile/'])('warm excludes generated autolinking from ignored %s', (ignored) => { + write(root, '.gitignore', `${ignored}\n`); + const app = ignored.startsWith('apps/') ? 'apps/mobile/' : ''; + const generated = `${app}android/build/generated`; + write(root, `${generated}/autolinking/autolinking.json`, 'source checkout paths'); + write(root, `${generated}/autolinking/package.json.sha`, 'unchanged checksum'); + write(root, `${generated}/other/output`, 'reusable output'); + write(root, `${generated}/autolinking-extra/keep`, 'unrelated output'); + const result = warm(); + expect(result.failed).toEqual([]); + expect(existsSync(join(target, generated, 'autolinking'))).toBe(false); + expect(readFileSync(join(target, generated, 'other/output'), 'utf8')).toBe('reusable output'); + expect(readFileSync(join(target, generated, 'autolinking-extra/keep'), 'utf8')).toBe('unrelated output'); + expect(readFileSync(join(root, generated, 'autolinking/autolinking.json'), 'utf8')).toBe('source checkout paths'); + write(target, `${generated}/autolinking/autolinking.json`, 'destination checkout paths'); + expect(warm().failed).toEqual([]); + expect(readFileSync(join(target, generated, 'autolinking/autolinking.json'), 'utf8')).toBe( + 'destination checkout paths', + ); +}); + test('exclusive directory publication preserves an empty directory created after the final precheck', () => { write(root, 'node_modules/pkg/index.js', 'source package'); publication.beforeMkdir = (path) => { diff --git a/packages/stim-cli/src/__tests__/worktree.test.ts b/packages/stim-cli/src/__tests__/worktree.test.ts index addf3996..c303de09 100644 --- a/packages/stim-cli/src/__tests__/worktree.test.ts +++ b/packages/stim-cli/src/__tests__/worktree.test.ts @@ -565,6 +565,8 @@ test('H1: cloneIgnoredEntries carries a top-level ignored $(...) filename as a l test('isCarrySkipped skips .DerivedData at any depth and treats .stim normally', () => { for (const rel of [ + 'android/build/generated/autolinking', + 'apps/mobile/android/build/generated/autolinking/autolinking.json', '.DerivedData', 'ios/build/.DerivedData', 'node_modules/expo-modules-jsi/apple/.DerivedData', diff --git a/packages/stim-cli/src/guide/lifecycle.ts b/packages/stim-cli/src/guide/lifecycle.ts index a866c534..fc6777d6 100644 --- a/packages/stim-cli/src/guide/lifecycle.ts +++ b/packages/stim-cli/src/guide/lifecycle.ts @@ -608,7 +608,9 @@ OPT-IN CONCURRENCY LIMITS (UNLIMITED BY DEFAULT) paths eligible under the main checkout's Git ignore rules, including .env and local configuration. The source's nonempty .worktreeexclude replaces its resolved worktree.exclude setting. Nested - registered worktrees and .DerivedData are excluded. Warm also skips paths + registered worktrees, .DerivedData, and android/build/generated/autolinking + caches are excluded, including in nested apps. Gradle regenerates autolinking + for the destination checkout on its next build. Warm also skips paths overlapping a nested destination worktree or below a symlink ancestor. Large copies stage privately outside Git working trees on the destination diff --git a/packages/stim-cli/src/worktree.ts b/packages/stim-cli/src/worktree.ts index 5c14ad57..286263d1 100644 --- a/packages/stim-cli/src/worktree.ts +++ b/packages/stim-cli/src/worktree.ts @@ -20,9 +20,12 @@ import { makeTemporaryDirectory, removeTemporaryEntry } from './temporary.ts'; const CARRY_SKIP_BASENAMES = new Set(['.DerivedData']); export function isCarrySkipped(rel: string): boolean { - return String(rel) - .split('/') - .some((seg) => CARRY_SKIP_BASENAMES.has(seg)); + return ( + /(^|\/)android\/build\/generated\/autolinking(\/|$)/.test(rel) || + String(rel) + .split('/') + .some((seg) => CARRY_SKIP_BASENAMES.has(seg)) + ); } export function gitCommonDir(cwd: string): string | null { @@ -226,7 +229,7 @@ function missingDestinationReason(target: string, rel: string): string | null { return lstatSync(join(target, rel), { throwIfNoEntry: false }) ? 'exists' : null; } -function publishMissingEntry(from: string, to: string): boolean { +function publishMissingEntry(from: string, to: string, rel: string): boolean { let cloned = true; const stat = lstatSync(from); if (stat.isSymbolicLink()) { @@ -248,7 +251,8 @@ function publishMissingEntry(from: string, to: string): boolean { } else if (stat.isDirectory()) { mkdirSync(to, { mode: stat.mode | 0o700 }); for (const name of readdirSync(from)) { - if (!CARRY_SKIP_BASENAMES.has(name) && !publishMissingEntry(join(from, name), join(to, name))) { + const child = `${rel}/${name}`; + if (!isCarrySkipped(child) && !publishMissingEntry(join(from, name), join(to, name), child)) { cloned = false; } } @@ -305,7 +309,7 @@ export function cloneIgnoredEntries({ continue; } mkdirSync(dirname(to), { recursive: true }); - if (!publishMissingEntry(destination, to)) cloned = false; + if (!publishMissingEntry(destination, to, rel)) cloned = false; copied.push(rel); } catch (e) { failed.push({ file: rel, error: String((e as Error)?.message || e) });