Skip to content

Commit 18bf417

Browse files
authored
[eas-cli] Use Git to pack Git projects (#2841)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. --> <!-- You can skip the changelog check by labeling the PR with "no changelog". --> # Why Currently, when `eas-cli` create a project tarball for EAS Build or Workflows, unless the user has `requireCommit: true` in `eas.json`, the project is being packed with a plain `makeShallowCopyAsync` which copies all the files that are deemed ignored by `ignore` package and `.easignore` or `.gitignore` files. This is suboptimal for `eas workflow:run` experience — a developer with a project with Git expects the repository to exist on EAS. # How This was a big pain. Previously, we had `GitClient` which required commit and its subclass, `GitNoCommitClient` which overrode some functions to allow uncommitted changes. On top of that, `GitClient` copied project with `git clone`, while `GitNoCommitClient` copied it with `makeShallowCopyAsync`. This meant `GitClient`'s copy would include `.git`, whereas `GitNoCommitClient` would not. Moreover, thus, `GitClient` would adhere to `.gitignore` and `GitNoCommitClient` would _not_ — only to `.easignore`. This pull request: - merges the two Git clients into one with `requireCommit` option - this causes `.git` directory to exist in copies with `requireCommit = false` - changes how we log changes caused by Git case sensitivity change (we need to handle the case where there is a preexisting dirty tree) - after we clone the repository, we delete files that should have been ignored according to `.easignore` - afterwards, we make a shallow copy of all the files over to the clone. This copies live changes to the working tree over to the clean clone. (They may only exist in `requireCommit = false` situation.) - adds better support for `.easignore` to `isFileIgnoredAsync`. As far as I can see the behavior looks like this: ![Bez tytułu-2025-01-11-0010](https://github.com/user-attachments/assets/3da4f36e-3f19-4573-b4e1-012faab7d4ef) The breaking change in here is that we're going to start adhering to `.easignore` even if someone has `requireCommit = true`. # Test Plan I used `easd build:inspect` a bunch to verify it works like I expect. I encourage fellow reviewers to do the same.
1 parent 0513fc5 commit 18bf417

File tree

9 files changed

+153
-92
lines changed

9 files changed

+153
-92
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This is the log of notable changes to EAS CLI and related packages.
66

77
### 🛠 Breaking changes
88

9+
- Use Git to archive projects containing a Git repository. (Previously, Git would only be used if `requireCommit` flag in `eas.json` was set to `true`.) ([#2841](https://github.com/expo/eas-cli/pull/2841) by [@sjchmiela](https://github.com/sjchmiela))
10+
911
### 🎉 New features
1012

1113
### 🐛 Bug fixes

packages/eas-cli/src/build/__tests__/configure-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe(ensureProjectConfiguredAsync, () => {
3838
}),
3939
});
4040
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeTruthy();
41-
const vcsClientMock = jest.mocked(new GitClient());
41+
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
4242
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
4343
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
4444
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
@@ -60,7 +60,7 @@ describe(ensureProjectConfiguredAsync, () => {
6060
});
6161
});
6262
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeFalsy();
63-
const vcsClientMock = jest.mocked(new GitClient());
63+
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
6464
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
6565
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
6666
vcsClientMock.trackFileAsync.mockImplementation(async () => {});

packages/eas-cli/src/commands/build/internal.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
66
import EasCommand from '../../commandUtils/EasCommand';
77
import { RequestedPlatform } from '../../platform';
88
import { enableJsonOutput } from '../../utils/json';
9-
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
10-
import NoVcsClient from '../../vcs/clients/noVcs';
9+
import GitClient from '../../vcs/clients/git';
1110

1211
/**
1312
* This command will be run on the EAS Build workers, when building
@@ -64,10 +63,16 @@ export default class BuildInternal extends EasCommand {
6463
vcsClient,
6564
} = await this.getContextAsync(BuildInternal, {
6665
nonInteractive: true,
67-
vcsClientOverride: process.env.EAS_NO_VCS ? new NoVcsClient() : new GitNoCommitClient(),
6866
withServerSideEnvironment: null,
6967
});
7068

69+
if (vcsClient instanceof GitClient) {
70+
// `build:internal` is run on EAS workers and the repo may have been changed
71+
// by pre-install hooks or other scripts. We don't want to require committing changes
72+
// to continue the build.
73+
vcsClient.requireCommit = false;
74+
}
75+
7176
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
7277

7378
await runBuildAndSubmitAsync({

packages/eas-cli/src/commands/project/onboarding.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ export default class Onboarding extends EasCommand {
162162
cloneMethod,
163163
});
164164

165-
const vcsClient = new GitClient(finalTargetProjectDirectory);
165+
const vcsClient = new GitClient({
166+
maybeCwdOverride: finalTargetProjectDirectory,
167+
requireCommit: false,
168+
});
166169
if (!app.githubRepository) {
167170
await fs.remove(path.join(finalTargetProjectDirectory, '.git'));
168171
await runCommandAsync({

packages/eas-cli/src/commands/submit/internal.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import AndroidSubmitCommand from '../../submit/android/AndroidSubmitCommand';
1818
import { SubmissionContext, createSubmissionContextAsync } from '../../submit/context';
1919
import IosSubmitCommand from '../../submit/ios/IosSubmitCommand';
2020
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
21-
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
22-
import NoVcsClient from '../../vcs/clients/noVcs';
21+
import GitClient from '../../vcs/clients/git';
2322

2423
/**
2524
* This command will be run on the EAS workers.
@@ -65,10 +64,16 @@ export default class SubmitInternal extends EasCommand {
6564
vcsClient,
6665
} = await this.getContextAsync(SubmitInternal, {
6766
nonInteractive: true,
68-
vcsClientOverride: process.env.EAS_NO_VCS ? new NoVcsClient() : new GitNoCommitClient(),
6967
withServerSideEnvironment: null,
7068
});
7169

70+
if (vcsClient instanceof GitClient) {
71+
// `build:internal` is run on EAS workers and the repo may have been changed
72+
// by pre-install hooks or other scripts. We don't want to require committing changes
73+
// to continue the build.
74+
vcsClient.requireCommit = false;
75+
}
76+
7277
const submissionProfile = await EasJsonUtils.getSubmitProfileAsync(
7378
EasJsonAccessor.fromProjectPath(projectDir),
7479
flags.platform,

packages/eas-cli/src/vcs/clients/git.ts

Lines changed: 127 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as PackageManagerUtils from '@expo/package-manager';
22
import spawnAsync from '@expo/spawn-async';
33
import { Errors } from '@oclif/core';
44
import chalk from 'chalk';
5+
import fs from 'fs-extra';
56
import path from 'path';
67

78
import Log, { learnMore } from '../../log';
@@ -14,11 +15,17 @@ import {
1415
gitStatusAsync,
1516
isGitInstalledAsync,
1617
} from '../git';
18+
import { EASIGNORE_FILENAME, Ignore, makeShallowCopyAsync } from '../local';
1719
import { Client } from '../vcs';
1820

1921
export default class GitClient extends Client {
20-
constructor(private readonly maybeCwdOverride?: string) {
22+
private readonly maybeCwdOverride?: string;
23+
public requireCommit: boolean;
24+
25+
constructor(options: { maybeCwdOverride?: string; requireCommit: boolean }) {
2126
super();
27+
this.maybeCwdOverride = options.maybeCwdOverride;
28+
this.requireCommit = options.requireCommit;
2229
}
2330

2431
public override async ensureRepoExistsAsync(): Promise<void> {
@@ -119,10 +126,6 @@ export default class GitClient extends Client {
119126
}
120127
}
121128

122-
public override async isCommitRequiredAsync(): Promise<boolean> {
123-
return await this.hasUncommittedChangesAsync();
124-
}
125-
126129
public override async showChangedFilesAsync(): Promise<void> {
127130
const gitStatusOutput = await gitStatusAsync({
128131
showUntracked: true,
@@ -144,50 +147,80 @@ export default class GitClient extends Client {
144147
).stdout.trim();
145148
}
146149

150+
public override async isCommitRequiredAsync(): Promise<boolean> {
151+
if (!this.requireCommit) {
152+
return false;
153+
}
154+
155+
return await this.hasUncommittedChangesAsync();
156+
}
157+
147158
public async makeShallowCopyAsync(destinationPath: string): Promise<void> {
148-
if (await this.hasUncommittedChangesAsync()) {
159+
if (await this.isCommitRequiredAsync()) {
149160
// it should already be checked before this function is called, but in case it wasn't
150161
// we want to ensure that any changes were introduced by call to `setGitCaseSensitivityAsync`
151162
throw new Error('You have some uncommitted changes in your repository.');
152163
}
164+
165+
const rootPath = await this.getRootPathAsync();
166+
153167
let gitRepoUri;
154168
if (process.platform === 'win32') {
155169
// getRootDirectoryAsync() will return C:/path/to/repo on Windows and path
156170
// prefix should be file:///
157-
gitRepoUri = `file:///${await this.getRootPathAsync()}`;
171+
gitRepoUri = `file:///${rootPath}`;
158172
} else {
159173
// getRootDirectoryAsync() will /path/to/repo, and path prefix should be
160174
// file:/// so only file:// needs to be prepended
161-
gitRepoUri = `file://${await this.getRootPathAsync()}`;
175+
gitRepoUri = `file://${rootPath}`;
162176
}
163-
const isCaseSensitive = await isGitCaseSensitiveAsync(this.maybeCwdOverride);
164-
await setGitCaseSensitivityAsync(true, this.maybeCwdOverride);
177+
178+
await assertEnablingGitCaseSensitivityDoesNotCauseNewUncommittedChangesAsync(rootPath);
179+
180+
const isCaseSensitive = await isGitCaseSensitiveAsync(rootPath);
165181
try {
166-
if (await this.hasUncommittedChangesAsync()) {
167-
Log.error('Detected inconsistent filename casing between your local filesystem and git.');
168-
Log.error('This will likely cause your build to fail. Impacted files:');
169-
await spawnAsync('git', ['status', '--short'], {
170-
stdio: 'inherit',
171-
cwd: this.maybeCwdOverride,
172-
});
173-
Log.newLine();
174-
Log.error(
175-
`Error: Resolve filename casing inconsistencies before proceeding. ${learnMore(
176-
'https://expo.fyi/macos-ignorecase'
177-
)}`
178-
);
179-
throw new Error('You have some uncommitted changes in your repository.');
180-
}
182+
await setGitCaseSensitivityAsync(true, rootPath);
181183
await spawnAsync(
182184
'git',
183185
['clone', '--no-hardlinks', '--depth', '1', gitRepoUri, destinationPath],
184-
{
185-
cwd: this.maybeCwdOverride,
186-
}
186+
{ cwd: rootPath }
187187
);
188+
189+
const sourceEasignorePath = path.join(rootPath, EASIGNORE_FILENAME);
190+
if (await fs.exists(sourceEasignorePath)) {
191+
const cachedFilesWeShouldHaveIgnored = (
192+
await spawnAsync(
193+
'git',
194+
[
195+
'ls-files',
196+
'--exclude-from',
197+
sourceEasignorePath,
198+
// `--ignored --cached` makes git print files that should be
199+
// ignored by rules from `--exclude-from`, but instead are currently cached.
200+
'--ignored',
201+
'--cached',
202+
// separates file names with null characters
203+
'-z',
204+
],
205+
{ cwd: destinationPath }
206+
)
207+
).stdout
208+
.split('\0')
209+
// ls-files' output is terminated by a null character
210+
.filter(file => file !== '');
211+
212+
await Promise.all(
213+
cachedFilesWeShouldHaveIgnored.map(file => fs.rm(path.join(destinationPath, file)))
214+
);
215+
}
188216
} finally {
189-
await setGitCaseSensitivityAsync(isCaseSensitive, this.maybeCwdOverride);
217+
await setGitCaseSensitivityAsync(isCaseSensitive, rootPath);
190218
}
219+
220+
// After we create the shallow Git copy, we copy the files
221+
// again. This way we include the changed and untracked files
222+
// (`git clone` only copies the committed changes).
223+
await makeShallowCopyAsync(rootPath, destinationPath);
191224
}
192225

193226
public override async getCommitHashAsync(): Promise<string | undefined> {
@@ -252,10 +285,24 @@ export default class GitClient extends Client {
252285
}
253286

254287
public override async isFileIgnoredAsync(filePath: string): Promise<boolean> {
288+
const rootPath = await this.getRootPathAsync();
289+
const easIgnorePath = path.join(rootPath, EASIGNORE_FILENAME);
290+
if (await fs.exists(easIgnorePath)) {
291+
const ignore = new Ignore(rootPath);
292+
const wouldNotBeCopiedToClone = ignore.ignores(filePath);
293+
const wouldBeDeletedFromClone =
294+
(
295+
await spawnAsync(
296+
'git',
297+
['ls-files', '--exclude-from', easIgnorePath, '--ignored', '--cached', filePath],
298+
{ cwd: rootPath }
299+
)
300+
).stdout.trim() !== '';
301+
return wouldNotBeCopiedToClone && wouldBeDeletedFromClone;
302+
}
303+
255304
try {
256-
await spawnAsync('git', ['check-ignore', '-q', filePath], {
257-
cwd: this.maybeCwdOverride ?? path.normalize(await this.getRootPathAsync()),
258-
});
305+
await spawnAsync('git', ['check-ignore', '-q', filePath], { cwd: rootPath });
259306
return true;
260307
} catch {
261308
return false;
@@ -386,3 +433,51 @@ async function setGitCaseSensitivityAsync(
386433
});
387434
}
388435
}
436+
437+
async function assertEnablingGitCaseSensitivityDoesNotCauseNewUncommittedChangesAsync(
438+
cwd: string
439+
): Promise<void> {
440+
// Remember uncommited changes before case sensitivity change
441+
// for later comparison so we log to the user only the files
442+
// that were marked as changed after the case sensitivity change.
443+
const uncommittedChangesBeforeCaseSensitivityChange = await gitStatusAsync({
444+
showUntracked: true,
445+
cwd,
446+
});
447+
448+
const isCaseSensitive = await isGitCaseSensitiveAsync(cwd);
449+
await setGitCaseSensitivityAsync(true, cwd);
450+
try {
451+
const uncommitedChangesAfterCaseSensitivityChange = await gitStatusAsync({
452+
showUntracked: true,
453+
cwd,
454+
});
455+
456+
if (
457+
uncommitedChangesAfterCaseSensitivityChange !== uncommittedChangesBeforeCaseSensitivityChange
458+
) {
459+
const baseUncommitedChangesSet = new Set(
460+
uncommittedChangesBeforeCaseSensitivityChange.split('\n')
461+
);
462+
463+
const errorMessage = [
464+
'Detected inconsistent filename casing between your local filesystem and git.',
465+
'This will likely cause your job to fail. Impacted files:',
466+
...uncommitedChangesAfterCaseSensitivityChange.split('\n').flatMap(changedFile => {
467+
// This file was changed before the case sensitivity change too.
468+
if (baseUncommitedChangesSet.has(changedFile)) {
469+
return [];
470+
}
471+
return [changedFile];
472+
}),
473+
`Resolve filename casing inconsistencies before proceeding. ${learnMore(
474+
'https://expo.fyi/macos-ignorecase'
475+
)}`,
476+
];
477+
478+
throw new Error(errorMessage.join('\n'));
479+
}
480+
} finally {
481+
await setGitCaseSensitivityAsync(isCaseSensitive, cwd);
482+
}
483+
}

packages/eas-cli/src/vcs/clients/gitNoCommit.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.

packages/eas-cli/src/vcs/index.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import chalk from 'chalk';
22

33
import GitClient from './clients/git';
4-
import GitNoCommitClient from './clients/gitNoCommit';
54
import NoVcsClient from './clients/noVcs';
65
import { Client } from './vcs';
76

@@ -23,8 +22,5 @@ export function resolveVcsClient(requireCommit: boolean = false): Client {
2322
}
2423
return new NoVcsClient();
2524
}
26-
if (requireCommit) {
27-
return new GitClient();
28-
}
29-
return new GitNoCommitClient();
25+
return new GitClient({ requireCommit });
3026
}

packages/eas-cli/src/vcs/local.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
33
import createIgnore, { Ignore as SingleFileIgnore } from 'ignore';
44
import path from 'path';
55

6-
const EASIGNORE_FILENAME = '.easignore';
6+
export const EASIGNORE_FILENAME = '.easignore';
77
const GITIGNORE_FILENAME = '.gitignore';
88

99
const DEFAULT_IGNORE = `

0 commit comments

Comments
 (0)