Skip to content

Commit

Permalink
[eas-cli] Use Git to pack Git projects (#2841)
Browse files Browse the repository at this point in the history
<!-- 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.
  • Loading branch information
sjchmiela authored Feb 4, 2025
1 parent 0513fc5 commit 18bf417
Show file tree
Hide file tree
Showing 9 changed files with 153 additions and 92 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🛠 Breaking changes

- 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))

### 🎉 New features

### 🐛 Bug fixes
Expand Down
4 changes: 2 additions & 2 deletions packages/eas-cli/src/build/__tests__/configure-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe(ensureProjectConfiguredAsync, () => {
}),
});
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeTruthy();
const vcsClientMock = jest.mocked(new GitClient());
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
Expand All @@ -60,7 +60,7 @@ describe(ensureProjectConfiguredAsync, () => {
});
});
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeFalsy();
const vcsClientMock = jest.mocked(new GitClient());
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
Expand Down
11 changes: 8 additions & 3 deletions packages/eas-cli/src/commands/build/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import EasCommand from '../../commandUtils/EasCommand';
import { RequestedPlatform } from '../../platform';
import { enableJsonOutput } from '../../utils/json';
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
import NoVcsClient from '../../vcs/clients/noVcs';
import GitClient from '../../vcs/clients/git';

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

if (vcsClient instanceof GitClient) {
// `build:internal` is run on EAS workers and the repo may have been changed
// by pre-install hooks or other scripts. We don't want to require committing changes
// to continue the build.
vcsClient.requireCommit = false;
}

await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);

await runBuildAndSubmitAsync({
Expand Down
5 changes: 4 additions & 1 deletion packages/eas-cli/src/commands/project/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ export default class Onboarding extends EasCommand {
cloneMethod,
});

const vcsClient = new GitClient(finalTargetProjectDirectory);
const vcsClient = new GitClient({
maybeCwdOverride: finalTargetProjectDirectory,
requireCommit: false,
});
if (!app.githubRepository) {
await fs.remove(path.join(finalTargetProjectDirectory, '.git'));
await runCommandAsync({
Expand Down
11 changes: 8 additions & 3 deletions packages/eas-cli/src/commands/submit/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ import AndroidSubmitCommand from '../../submit/android/AndroidSubmitCommand';
import { SubmissionContext, createSubmissionContextAsync } from '../../submit/context';
import IosSubmitCommand from '../../submit/ios/IosSubmitCommand';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
import NoVcsClient from '../../vcs/clients/noVcs';
import GitClient from '../../vcs/clients/git';

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

if (vcsClient instanceof GitClient) {
// `build:internal` is run on EAS workers and the repo may have been changed
// by pre-install hooks or other scripts. We don't want to require committing changes
// to continue the build.
vcsClient.requireCommit = false;
}

const submissionProfile = await EasJsonUtils.getSubmitProfileAsync(
EasJsonAccessor.fromProjectPath(projectDir),
flags.platform,
Expand Down
159 changes: 127 additions & 32 deletions packages/eas-cli/src/vcs/clients/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as PackageManagerUtils from '@expo/package-manager';
import spawnAsync from '@expo/spawn-async';
import { Errors } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';

import Log, { learnMore } from '../../log';
Expand All @@ -14,11 +15,17 @@ import {
gitStatusAsync,
isGitInstalledAsync,
} from '../git';
import { EASIGNORE_FILENAME, Ignore, makeShallowCopyAsync } from '../local';
import { Client } from '../vcs';

export default class GitClient extends Client {
constructor(private readonly maybeCwdOverride?: string) {
private readonly maybeCwdOverride?: string;
public requireCommit: boolean;

constructor(options: { maybeCwdOverride?: string; requireCommit: boolean }) {
super();
this.maybeCwdOverride = options.maybeCwdOverride;
this.requireCommit = options.requireCommit;
}

public override async ensureRepoExistsAsync(): Promise<void> {
Expand Down Expand Up @@ -119,10 +126,6 @@ export default class GitClient extends Client {
}
}

public override async isCommitRequiredAsync(): Promise<boolean> {
return await this.hasUncommittedChangesAsync();
}

public override async showChangedFilesAsync(): Promise<void> {
const gitStatusOutput = await gitStatusAsync({
showUntracked: true,
Expand All @@ -144,50 +147,80 @@ export default class GitClient extends Client {
).stdout.trim();
}

public override async isCommitRequiredAsync(): Promise<boolean> {
if (!this.requireCommit) {
return false;
}

return await this.hasUncommittedChangesAsync();
}

public async makeShallowCopyAsync(destinationPath: string): Promise<void> {
if (await this.hasUncommittedChangesAsync()) {
if (await this.isCommitRequiredAsync()) {
// it should already be checked before this function is called, but in case it wasn't
// we want to ensure that any changes were introduced by call to `setGitCaseSensitivityAsync`
throw new Error('You have some uncommitted changes in your repository.');
}

const rootPath = await this.getRootPathAsync();

let gitRepoUri;
if (process.platform === 'win32') {
// getRootDirectoryAsync() will return C:/path/to/repo on Windows and path
// prefix should be file:///
gitRepoUri = `file:///${await this.getRootPathAsync()}`;
gitRepoUri = `file:///${rootPath}`;
} else {
// getRootDirectoryAsync() will /path/to/repo, and path prefix should be
// file:/// so only file:// needs to be prepended
gitRepoUri = `file://${await this.getRootPathAsync()}`;
gitRepoUri = `file://${rootPath}`;
}
const isCaseSensitive = await isGitCaseSensitiveAsync(this.maybeCwdOverride);
await setGitCaseSensitivityAsync(true, this.maybeCwdOverride);

await assertEnablingGitCaseSensitivityDoesNotCauseNewUncommittedChangesAsync(rootPath);

const isCaseSensitive = await isGitCaseSensitiveAsync(rootPath);
try {
if (await this.hasUncommittedChangesAsync()) {
Log.error('Detected inconsistent filename casing between your local filesystem and git.');
Log.error('This will likely cause your build to fail. Impacted files:');
await spawnAsync('git', ['status', '--short'], {
stdio: 'inherit',
cwd: this.maybeCwdOverride,
});
Log.newLine();
Log.error(
`Error: Resolve filename casing inconsistencies before proceeding. ${learnMore(
'https://expo.fyi/macos-ignorecase'
)}`
);
throw new Error('You have some uncommitted changes in your repository.');
}
await setGitCaseSensitivityAsync(true, rootPath);
await spawnAsync(
'git',
['clone', '--no-hardlinks', '--depth', '1', gitRepoUri, destinationPath],
{
cwd: this.maybeCwdOverride,
}
{ cwd: rootPath }
);

const sourceEasignorePath = path.join(rootPath, EASIGNORE_FILENAME);
if (await fs.exists(sourceEasignorePath)) {
const cachedFilesWeShouldHaveIgnored = (
await spawnAsync(
'git',
[
'ls-files',
'--exclude-from',
sourceEasignorePath,
// `--ignored --cached` makes git print files that should be
// ignored by rules from `--exclude-from`, but instead are currently cached.
'--ignored',
'--cached',
// separates file names with null characters
'-z',
],
{ cwd: destinationPath }
)
).stdout
.split('\0')
// ls-files' output is terminated by a null character
.filter(file => file !== '');

await Promise.all(
cachedFilesWeShouldHaveIgnored.map(file => fs.rm(path.join(destinationPath, file)))
);
}
} finally {
await setGitCaseSensitivityAsync(isCaseSensitive, this.maybeCwdOverride);
await setGitCaseSensitivityAsync(isCaseSensitive, rootPath);
}

// After we create the shallow Git copy, we copy the files
// again. This way we include the changed and untracked files
// (`git clone` only copies the committed changes).
await makeShallowCopyAsync(rootPath, destinationPath);
}

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

public override async isFileIgnoredAsync(filePath: string): Promise<boolean> {
const rootPath = await this.getRootPathAsync();
const easIgnorePath = path.join(rootPath, EASIGNORE_FILENAME);
if (await fs.exists(easIgnorePath)) {
const ignore = new Ignore(rootPath);
const wouldNotBeCopiedToClone = ignore.ignores(filePath);
const wouldBeDeletedFromClone =
(
await spawnAsync(
'git',
['ls-files', '--exclude-from', easIgnorePath, '--ignored', '--cached', filePath],
{ cwd: rootPath }
)
).stdout.trim() !== '';
return wouldNotBeCopiedToClone && wouldBeDeletedFromClone;
}

try {
await spawnAsync('git', ['check-ignore', '-q', filePath], {
cwd: this.maybeCwdOverride ?? path.normalize(await this.getRootPathAsync()),
});
await spawnAsync('git', ['check-ignore', '-q', filePath], { cwd: rootPath });
return true;
} catch {
return false;
Expand Down Expand Up @@ -386,3 +433,51 @@ async function setGitCaseSensitivityAsync(
});
}
}

async function assertEnablingGitCaseSensitivityDoesNotCauseNewUncommittedChangesAsync(
cwd: string
): Promise<void> {
// Remember uncommited changes before case sensitivity change
// for later comparison so we log to the user only the files
// that were marked as changed after the case sensitivity change.
const uncommittedChangesBeforeCaseSensitivityChange = await gitStatusAsync({
showUntracked: true,
cwd,
});

const isCaseSensitive = await isGitCaseSensitiveAsync(cwd);
await setGitCaseSensitivityAsync(true, cwd);
try {
const uncommitedChangesAfterCaseSensitivityChange = await gitStatusAsync({
showUntracked: true,
cwd,
});

if (
uncommitedChangesAfterCaseSensitivityChange !== uncommittedChangesBeforeCaseSensitivityChange
) {
const baseUncommitedChangesSet = new Set(
uncommittedChangesBeforeCaseSensitivityChange.split('\n')
);

const errorMessage = [
'Detected inconsistent filename casing between your local filesystem and git.',
'This will likely cause your job to fail. Impacted files:',
...uncommitedChangesAfterCaseSensitivityChange.split('\n').flatMap(changedFile => {
// This file was changed before the case sensitivity change too.
if (baseUncommitedChangesSet.has(changedFile)) {
return [];
}
return [changedFile];
}),
`Resolve filename casing inconsistencies before proceeding. ${learnMore(
'https://expo.fyi/macos-ignorecase'
)}`,
];

throw new Error(errorMessage.join('\n'));
}
} finally {
await setGitCaseSensitivityAsync(isCaseSensitive, cwd);
}
}
45 changes: 0 additions & 45 deletions packages/eas-cli/src/vcs/clients/gitNoCommit.ts

This file was deleted.

6 changes: 1 addition & 5 deletions packages/eas-cli/src/vcs/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import chalk from 'chalk';

import GitClient from './clients/git';
import GitNoCommitClient from './clients/gitNoCommit';
import NoVcsClient from './clients/noVcs';
import { Client } from './vcs';

Expand All @@ -23,8 +22,5 @@ export function resolveVcsClient(requireCommit: boolean = false): Client {
}
return new NoVcsClient();
}
if (requireCommit) {
return new GitClient();
}
return new GitNoCommitClient();
return new GitClient({ requireCommit });
}
2 changes: 1 addition & 1 deletion packages/eas-cli/src/vcs/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'fs-extra';
import createIgnore, { Ignore as SingleFileIgnore } from 'ignore';
import path from 'path';

const EASIGNORE_FILENAME = '.easignore';
export const EASIGNORE_FILENAME = '.easignore';
const GITIGNORE_FILENAME = '.gitignore';

const DEFAULT_IGNORE = `
Expand Down

0 comments on commit 18bf417

Please sign in to comment.