From 880a1dff1d23ba357a0179d789d02bd31c5bf5a8 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 11 Sep 2026 14:25:49 +0100 Subject: [PATCH 1/2] [eas-cli] Read .easignore next to eas.json, not only at the git root --- .../context/VcsClientContextField.ts | 2 +- .../eas-cli/src/vcs/__tests__/local-test.ts | 95 ++++++++++++++- .../src/vcs/clients/__tests__/git.test.ts | 109 ++++++++++++++++++ .../src/vcs/clients/__tests__/noVcs.test.ts | 27 +++++ packages/eas-cli/src/vcs/clients/git.ts | 23 ++-- packages/eas-cli/src/vcs/clients/noVcs.ts | 8 +- packages/eas-cli/src/vcs/index.ts | 6 +- packages/eas-cli/src/vcs/local.ts | 61 ++++++++-- 8 files changed, 303 insertions(+), 28 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/context/VcsClientContextField.ts b/packages/eas-cli/src/commandUtils/context/VcsClientContextField.ts index d4f3527783..98307d88d0 100644 --- a/packages/eas-cli/src/commandUtils/context/VcsClientContextField.ts +++ b/packages/eas-cli/src/commandUtils/context/VcsClientContextField.ts @@ -13,6 +13,6 @@ export default class VcsClientContextField extends ContextField { const projectDir = await findProjectDirAndVerifyProjectSetupAsync(); const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir); const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor); - return resolveVcsClient(config?.requireCommit); + return resolveVcsClient(config?.requireCommit, projectDir); } } diff --git a/packages/eas-cli/src/vcs/__tests__/local-test.ts b/packages/eas-cli/src/vcs/__tests__/local-test.ts index abb9796e8b..d83f5fa295 100644 --- a/packages/eas-cli/src/vcs/__tests__/local-test.ts +++ b/packages/eas-cli/src/vcs/__tests__/local-test.ts @@ -1,6 +1,7 @@ +import path from 'path'; import { vol } from 'memfs'; -import { Ignore } from '../local'; +import { Ignore, resolveEasignorePathAsync } from '../local'; jest.mock('fs'); @@ -111,4 +112,96 @@ describe(Ignore, () => { const ignore = await Ignore.createForCopyingAsync('/root'); expect(() => ignore.ignores('dir/test')).not.toThrowError(); }); + + it('reads .easignore from the project directory when it is not at the copy root', async () => { + vol.fromJSON( + { + 'apps/app-a/.easignore': 'secret.txt\n', + }, + '/root' + ); + + const ignore = await Ignore.createForCopyingAsync('/root', '/root/apps/app-a'); + expect(ignore.ignores('secret.txt')).toBe(true); + }); + + it('prefers the project directory .easignore over the root .easignore', async () => { + vol.fromJSON( + { + '.easignore': 'from-root.txt\n', + 'apps/app-a/.easignore': 'from-app.txt\n', + }, + '/root' + ); + + const ignore = await Ignore.createForCopyingAsync('/root', '/root/apps/app-a'); + expect(ignore.ignores('from-app.txt')).toBe(true); + expect(ignore.ignores('from-root.txt')).toBe(false); + }); + + it('falls back to the root .easignore when the project directory has none', async () => { + vol.fromJSON( + { + '.easignore': 'from-root.txt\n', + 'apps/app-a/eas.json': '{}', + }, + '/root' + ); + + const ignore = await Ignore.createForCopyingAsync('/root', '/root/apps/app-a'); + expect(ignore.ignores('from-root.txt')).toBe(true); + }); +}); + +describe(resolveEasignorePathAsync, () => { + it('returns the project directory .easignore when it exists', async () => { + vol.fromJSON( + { + 'apps/app-a/.easignore': 'secret.txt\n', + }, + '/root' + ); + + await expect(resolveEasignorePathAsync('/root', '/root/apps/app-a')).resolves.toBe( + path.join('/root/apps/app-a', '.easignore') + ); + }); + + it('returns the root .easignore when the project directory has none', async () => { + vol.fromJSON( + { + '.easignore': 'from-root.txt\n', + }, + '/root' + ); + + await expect(resolveEasignorePathAsync('/root', '/root/apps/app-a')).resolves.toBe( + path.join('/root', '.easignore') + ); + }); + + it('prefers the project directory .easignore when both exist', async () => { + vol.fromJSON( + { + '.easignore': 'from-root.txt\n', + 'apps/app-a/.easignore': 'from-app.txt\n', + }, + '/root' + ); + + await expect(resolveEasignorePathAsync('/root', '/root/apps/app-a')).resolves.toBe( + path.join('/root/apps/app-a', '.easignore') + ); + }); + + it('returns null when neither file exists', async () => { + vol.fromJSON( + { + 'apps/app-a/eas.json': '{}', + }, + '/root' + ); + + await expect(resolveEasignorePathAsync('/root', '/root/apps/app-a')).resolves.toBeNull(); + }); }); diff --git a/packages/eas-cli/src/vcs/clients/__tests__/git.test.ts b/packages/eas-cli/src/vcs/clients/__tests__/git.test.ts index 4e7fec8cdf..7fad92c7a8 100644 --- a/packages/eas-cli/src/vcs/clients/__tests__/git.test.ts +++ b/packages/eas-cli/src/vcs/clients/__tests__/git.test.ts @@ -330,4 +330,113 @@ describe('git', () => { }); }); }); + + describe('nested project .easignore', () => { + let repoRoot: string; + let projectDir: string; + + afterEach(async () => { + if (repoRoot) { + await fs.rm(repoRoot, { recursive: true, force: true }); + } + }); + + async function setupNestedProjectAsync(): Promise { + repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-git-test-')); + await spawnAsync('git', ['init'], { cwd: repoRoot }); + projectDir = path.join(repoRoot, 'apps', 'app-a'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, '.easignore'), 'secret.txt\n'); + await fs.writeFile(path.join(repoRoot, 'secret.txt'), 'secret'); + await fs.writeFile(path.join(repoRoot, 'kept.txt'), 'kept'); + return new GitClient({ + requireCommit: false, + maybeCwdOverride: repoRoot, + projectDir, + }); + } + + it('isFileIgnoredAsync reads .easignore next to the project, not only at the git root', async () => { + const vcs = await setupNestedProjectAsync(); + expect(await vcs.isFileIgnoredAsync('secret.txt')).toBe(true); + expect(await vcs.isFileIgnoredAsync('kept.txt')).toBe(false); + }); + + it('makeShallowCopyAsync excludes files listed in the project .easignore', async () => { + const vcs = await setupNestedProjectAsync(); + await spawnAsync('git', ['add', 'kept.txt', 'secret.txt', 'apps/app-a/.easignore'], { + cwd: repoRoot, + }); + await spawnAsync('git', ['commit', '-m', 'setup'], { cwd: repoRoot }); + + const copyRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-git-test-')); + await expect(vcs.makeShallowCopyAsync(copyRoot)).resolves.not.toThrow(); + await expect(fs.stat(path.join(copyRoot, 'secret.txt'))).rejects.toThrow('ENOENT'); + await expect(fs.stat(path.join(copyRoot, 'kept.txt'))).resolves.not.toThrow(); + await fs.rm(copyRoot, { recursive: true, force: true }); + }); + + it('falls back to the git root .easignore when the project directory has none', async () => { + repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-git-test-')); + await spawnAsync('git', ['init'], { cwd: repoRoot }); + projectDir = path.join(repoRoot, 'apps', 'app-a'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.easignore'), 'secret.txt\n'); + await fs.writeFile(path.join(repoRoot, 'secret.txt'), 'secret'); + await fs.writeFile(path.join(repoRoot, 'kept.txt'), 'kept'); + + const vcs = new GitClient({ + requireCommit: false, + maybeCwdOverride: repoRoot, + projectDir, + }); + + expect(await vcs.isFileIgnoredAsync('secret.txt')).toBe(true); + expect(await vcs.isFileIgnoredAsync('kept.txt')).toBe(false); + }); + + it('prefers the project .easignore when both project and root files exist', async () => { + repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-git-test-')); + await spawnAsync('git', ['init'], { cwd: repoRoot }); + projectDir = path.join(repoRoot, 'apps', 'app-a'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.easignore'), 'from-root.txt\n'); + await fs.writeFile(path.join(projectDir, '.easignore'), 'from-app.txt\n'); + await fs.writeFile(path.join(repoRoot, 'from-root.txt'), 'root'); + await fs.writeFile(path.join(repoRoot, 'from-app.txt'), 'app'); + + const vcs = new GitClient({ + requireCommit: false, + maybeCwdOverride: repoRoot, + projectDir, + }); + + expect(await vcs.isFileIgnoredAsync('from-app.txt')).toBe(true); + expect(await vcs.isFileIgnoredAsync('from-root.txt')).toBe(false); + }); + + it('makeShallowCopyAsync with requireCommit excludes files from the project .easignore', async () => { + repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-git-test-')); + await spawnAsync('git', ['init'], { cwd: repoRoot }); + projectDir = path.join(repoRoot, 'apps', 'app-a'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, '.easignore'), 'secret.txt\n'); + await fs.writeFile(path.join(repoRoot, 'secret.txt'), 'secret'); + await fs.writeFile(path.join(repoRoot, 'kept.txt'), 'kept'); + await spawnAsync('git', ['add', '.'], { cwd: repoRoot }); + await spawnAsync('git', ['commit', '-m', 'setup'], { cwd: repoRoot }); + + const vcs = new GitClient({ + requireCommit: true, + maybeCwdOverride: repoRoot, + projectDir, + }); + + const copyRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-git-test-')); + await expect(vcs.makeShallowCopyAsync(copyRoot)).resolves.not.toThrow(); + await expect(fs.stat(path.join(copyRoot, 'secret.txt'))).rejects.toThrow('ENOENT'); + await expect(fs.stat(path.join(copyRoot, 'kept.txt'))).resolves.not.toThrow(); + await fs.rm(copyRoot, { recursive: true, force: true }); + }); + }); }); diff --git a/packages/eas-cli/src/vcs/clients/__tests__/noVcs.test.ts b/packages/eas-cli/src/vcs/clients/__tests__/noVcs.test.ts index c86143482d..1b412c2f38 100644 --- a/packages/eas-cli/src/vcs/clients/__tests__/noVcs.test.ts +++ b/packages/eas-cli/src/vcs/clients/__tests__/noVcs.test.ts @@ -40,5 +40,32 @@ describe('noVcs', () => { process.env.EAS_PROJECT_ROOT = '/app'; expect(await vcs.getRootPathAsync()).toBe('/app'); }); + + it('isFileIgnoredAsync reads .easignore next to the project, not only at the git root', async () => { + await spawnAsync('git', ['init'], { cwd: repoRoot }); + const projectDir = path.join(repoRoot, 'apps', 'app-a'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, '.easignore'), 'secret.txt\n'); + await fs.writeFile(path.join(repoRoot, 'secret.txt'), 'secret'); + await fs.writeFile(path.join(repoRoot, 'kept.txt'), 'kept'); + + vcs = new NoVcsClient({ cwdOverride: repoRoot, projectDir }); + + expect(await vcs.isFileIgnoredAsync('secret.txt')).toBe(true); + expect(await vcs.isFileIgnoredAsync('kept.txt')).toBe(false); + }); + + it('isFileIgnoredAsync prefers the project .easignore when both project and root files exist', async () => { + await spawnAsync('git', ['init'], { cwd: repoRoot }); + const projectDir = path.join(repoRoot, 'apps', 'app-a'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.easignore'), 'from-root.txt\n'); + await fs.writeFile(path.join(projectDir, '.easignore'), 'from-app.txt\n'); + + vcs = new NoVcsClient({ cwdOverride: repoRoot, projectDir }); + + expect(await vcs.isFileIgnoredAsync('from-app.txt')).toBe(true); + expect(await vcs.isFileIgnoredAsync('from-root.txt')).toBe(false); + }); }); }); diff --git a/packages/eas-cli/src/vcs/clients/git.ts b/packages/eas-cli/src/vcs/clients/git.ts index ffb8140660..2092ba3e5e 100644 --- a/packages/eas-cli/src/vcs/clients/git.ts +++ b/packages/eas-cli/src/vcs/clients/git.ts @@ -16,19 +16,21 @@ import { gitStatusAsync, isGitInstalledAsync, } from '../git'; -import { EASIGNORE_FILENAME, Ignore, makeShallowCopyAsync } from '../local'; +import { Ignore, makeShallowCopyAsync, resolveEasignorePathAsync } from '../local'; import { Client } from '../vcs'; let hasWarnedAboutEasignoreInRequireCommit = false; export default class GitClient extends Client { private readonly maybeCwdOverride?: string; + private readonly projectDir?: string; public requireCommit: boolean; - constructor(options: { maybeCwdOverride?: string; requireCommit: boolean }) { + constructor(options: { maybeCwdOverride?: string; requireCommit: boolean; projectDir?: string }) { super(); this.maybeCwdOverride = options.maybeCwdOverride; this.requireCommit = options.requireCommit; + this.projectDir = options.projectDir ?? options.maybeCwdOverride; } public override async ensureRepoExistsAsync(): Promise { @@ -166,8 +168,8 @@ export default class GitClient extends Client { } const rootPath = await this.getRootPathAsync(); - const sourceEasignorePath = path.join(rootPath, EASIGNORE_FILENAME); - const doesEasignoreExist = await fs.exists(sourceEasignorePath); + const sourceEasignorePath = await resolveEasignorePathAsync(rootPath, this.projectDir); + const doesEasignoreExist = sourceEasignorePath !== null; const shouldSuppressWarning = hasWarnedAboutEasignoreInRequireCommit || getenv.boolish('EAS_SUPPRESS_REQUIRE_COMMIT_EASIGNORE_WARNING', false); @@ -212,8 +214,7 @@ export default class GitClient extends Client { { cwd: rootPath } ); - const sourceEasignorePath = path.join(rootPath, EASIGNORE_FILENAME); - if (await fs.exists(sourceEasignorePath)) { + if (sourceEasignorePath) { Log.debug('.easignore exists, deleting files that should be ignored', { sourceEasignorePath, }); @@ -254,7 +255,7 @@ export default class GitClient extends Client { // Special-case `.git` which `git ls-files` will never consider ignored. // We don't want to ignore anything by default. We want to know what does // the user want to ignore. - const ignore = await Ignore.createForCheckingAsync(rootPath); + const ignore = await Ignore.createForCheckingAsync(rootPath, this.projectDir); if (ignore.ignores('.git')) { await fs.rm(path.join(destinationPath, '.git'), { recursive: true, force: true }); Log.debug('deleted .git', { @@ -274,7 +275,7 @@ export default class GitClient extends Client { // // We only do this if `requireCommit` is false because `requireCommit: true` // setups expect no changes in files (e.g. locked files should remain locked). - await makeShallowCopyAsync(rootPath, destinationPath); + await makeShallowCopyAsync(rootPath, destinationPath, this.projectDir); } else { Log.debug('not making shallow copy', { requireCommit: this.requireCommit }); } @@ -355,9 +356,9 @@ export default class GitClient extends Client { isTracked = false; } - const easIgnorePath = path.join(rootPath, EASIGNORE_FILENAME); - if (await fs.exists(easIgnorePath)) { - const ignore = await Ignore.createForCheckingAsync(rootPath); + const easIgnorePath = await resolveEasignorePathAsync(rootPath, this.projectDir); + if (easIgnorePath) { + const ignore = await Ignore.createForCheckingAsync(rootPath, this.projectDir); const wouldNotBeCopiedToClone = ignore.ignores(filePath); const wouldBeDeletedFromClone = ( diff --git a/packages/eas-cli/src/vcs/clients/noVcs.ts b/packages/eas-cli/src/vcs/clients/noVcs.ts index f1c885bf71..16f864af35 100644 --- a/packages/eas-cli/src/vcs/clients/noVcs.ts +++ b/packages/eas-cli/src/vcs/clients/noVcs.ts @@ -9,10 +9,12 @@ let hasWarnedAboutEasProjectRoot = false; export default class NoVcsClient extends Client { private readonly cwdOverride?: string; + private readonly projectDir?: string; - constructor(options: { cwdOverride?: string } = {}) { + constructor(options: { cwdOverride?: string; projectDir?: string } = {}) { super(); this.cwdOverride = options.cwdOverride; + this.projectDir = options.projectDir ?? options.cwdOverride; } public async getRootPathAsync(): Promise { @@ -47,12 +49,12 @@ export default class NoVcsClient extends Client { public async makeShallowCopyAsync(destinationPath: string): Promise { const srcPath = path.normalize(await this.getRootPathAsync()); - await makeShallowCopyAsync(srcPath, destinationPath); + await makeShallowCopyAsync(srcPath, destinationPath, this.projectDir); } public override async isFileIgnoredAsync(filePath: string): Promise { const rootPath = path.normalize(await this.getRootPathAsync()); - const ignore = await Ignore.createForCheckingAsync(rootPath); + const ignore = await Ignore.createForCheckingAsync(rootPath, this.projectDir); return ignore.ignores(filePath); } diff --git a/packages/eas-cli/src/vcs/index.ts b/packages/eas-cli/src/vcs/index.ts index e81b662bc8..e2e62ed9ce 100644 --- a/packages/eas-cli/src/vcs/index.ts +++ b/packages/eas-cli/src/vcs/index.ts @@ -8,7 +8,7 @@ const NO_VCS_WARNING = `Using EAS CLI without version control system is not reco let wasNoVcsWarningPrinted = false; -export function resolveVcsClient(requireCommit: boolean = false): Client { +export function resolveVcsClient(requireCommit: boolean = false, projectDir?: string): Client { if (process.env.EAS_NO_VCS) { if (process.env.NODE_ENV !== 'test') { if (!wasNoVcsWarningPrinted) { @@ -20,7 +20,7 @@ export function resolveVcsClient(requireCommit: boolean = false): Client { wasNoVcsWarningPrinted = true; } } - return new NoVcsClient(); + return new NoVcsClient({ projectDir }); } - return new GitClient({ requireCommit }); + return new GitClient({ requireCommit, projectDir }); } diff --git a/packages/eas-cli/src/vcs/local.ts b/packages/eas-cli/src/vcs/local.ts index 2e8abf47c2..04c96ee3f9 100644 --- a/packages/eas-cli/src/vcs/local.ts +++ b/packages/eas-cli/src/vcs/local.ts @@ -9,6 +9,40 @@ import Log from '../log'; export const EASIGNORE_FILENAME = '.easignore'; const GITIGNORE_FILENAME = '.gitignore'; +/** + * Resolve the `.easignore` file to apply. + * + * Prefer a file next to `eas.json` (the EAS project directory) when it exists, + * otherwise fall back to the repository / copy root. Patterns stay relative to + * `rootDir` regardless of which file is chosen. + */ +export async function resolveEasignorePathAsync( + rootDir: string, + projectDir?: string +): Promise { + const rootEasignorePath = path.join(rootDir, EASIGNORE_FILENAME); + + if (projectDir) { + const projectEasignorePath = path.join(projectDir, EASIGNORE_FILENAME); + if ( + path.resolve(projectEasignorePath) !== path.resolve(rootEasignorePath) && + (await fsExtra.pathExists(projectEasignorePath)) + ) { + Log.debug('using .easignore from the project directory', { + projectEasignorePath, + rootEasignorePath, + }); + return projectEasignorePath; + } + } + + if (await fsExtra.pathExists(rootEasignorePath)) { + return rootEasignorePath; + } + + return null; +} + /** * Ignore wraps the 'ignore' package to support multiple .gitignore files * in subdirectories. @@ -18,14 +52,19 @@ const GITIGNORE_FILENAME = '.gitignore'; * file will still be ignored, * - node_modules is always ignored, * - if .easignore exists, .gitignore files are not used. + * - `.easignore` is read from `projectDir` (next to eas.json) when present, + * otherwise from `rootDir`. */ export class Ignore { public ignoreMapping: (readonly [string, SingleFileIgnore])[] = []; - private constructor(private readonly rootDir: string) {} + private constructor( + private readonly rootDir: string, + private readonly projectDir?: string + ) {} - static async createForCopyingAsync(rootDir: string): Promise { - const ignore = new Ignore(rootDir); + static async createForCopyingAsync(rootDir: string, projectDir?: string): Promise { + const ignore = new Ignore(rootDir, projectDir); await ignore.initIgnoreAsync({ defaultIgnore: ` .git @@ -36,8 +75,8 @@ node_modules } /** Does not include the default .git and node_modules ignore rules. */ - static async createForCheckingAsync(rootDir: string): Promise { - const ignore = new Ignore(rootDir); + static async createForCheckingAsync(rootDir: string, projectDir?: string): Promise { + const ignore = new Ignore(rootDir, projectDir); await ignore.initIgnoreAsync({ defaultIgnore: ``, }); @@ -45,8 +84,8 @@ node_modules } public async initIgnoreAsync({ defaultIgnore }: { defaultIgnore: string }): Promise { - const easIgnorePath = path.join(this.rootDir, EASIGNORE_FILENAME); - if (await fsExtra.pathExists(easIgnorePath)) { + const easIgnorePath = await resolveEasignorePathAsync(this.rootDir, this.projectDir); + if (easIgnorePath) { this.ignoreMapping = [ ['', createIgnore().add(defaultIgnore)], ['', createIgnore().add(await fsExtra.readFile(easIgnorePath, 'utf-8'))], @@ -93,7 +132,11 @@ node_modules } } -export async function makeShallowCopyAsync(_src: string, dst: string): Promise { +export async function makeShallowCopyAsync( + _src: string, + dst: string, + projectDir?: string +): Promise { // `node:fs` on Windows adds a namespace prefix (e.g. `\\?\`) to the path provided // to the `filter` function in `fs.cp`. We need to ensure that we compare the right paths // (both with prefix), otherwise the `relativePath` ends up being wrong and causes no files @@ -101,7 +144,7 @@ export async function makeShallowCopyAsync(_src: string, dst: string): Promise Date: Fri, 11 Sep 2026 14:26:05 +0100 Subject: [PATCH 2/2] [eas-cli] Add changelog entry for project-directory .easignore lookup --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ccaaeccf..1e4708f3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic)) +- [eas-cli] Read `.easignore` from the project directory (next to `eas.json`) when present, falling back to the repository root. ([#4387](https://github.com/expo/eas-cli/pull/4387) by [@cpruijsen](https://github.com/cpruijsen)) ### ๐Ÿงน Chores