Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-easignore-project-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---

Read `.easignore` from the project directory (next to `eas.json`) when present, falling back to the repository root.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export default class VcsClientContextField extends ContextField<Client> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
return resolveVcsClient(config?.requireCommit);
return resolveVcsClient(config?.requireCommit, projectDir);
}
}
95 changes: 94 additions & 1 deletion packages/eas-cli/src/vcs/__tests__/local-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from 'path';
import { vol } from 'memfs';

import { Ignore } from '../local';
import { Ignore, resolveEasignorePathAsync } from '../local';

jest.mock('fs');

Expand Down Expand Up @@ -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();
});
});
109 changes: 109 additions & 0 deletions packages/eas-cli/src/vcs/clients/__tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitClient> {
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 });
});
});
});
27 changes: 27 additions & 0 deletions packages/eas-cli/src/vcs/clients/__tests__/noVcs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
23 changes: 12 additions & 11 deletions packages/eas-cli/src/vcs/clients/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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', {
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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 =
(
Expand Down
8 changes: 5 additions & 3 deletions packages/eas-cli/src/vcs/clients/noVcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down Expand Up @@ -47,12 +49,12 @@ export default class NoVcsClient extends Client {

public async makeShallowCopyAsync(destinationPath: string): Promise<void> {
const srcPath = path.normalize(await this.getRootPathAsync());
await makeShallowCopyAsync(srcPath, destinationPath);
await makeShallowCopyAsync(srcPath, destinationPath, this.projectDir);
}

public override async isFileIgnoredAsync(filePath: string): Promise<boolean> {
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);
}

Expand Down
6 changes: 3 additions & 3 deletions packages/eas-cli/src/vcs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 });
}
Loading
Loading