diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe9116884..46e1fbbc6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/eas-cli/src/build/__tests__/configure-test.ts b/packages/eas-cli/src/build/__tests__/configure-test.ts index 04050a4099..21802e2932 100644 --- a/packages/eas-cli/src/build/__tests__/configure-test.ts +++ b/packages/eas-cli/src/build/__tests__/configure-test.ts @@ -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 () => {}); @@ -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 () => {}); diff --git a/packages/eas-cli/src/commands/build/internal.ts b/packages/eas-cli/src/commands/build/internal.ts index 72e3747445..ad8676692f 100644 --- a/packages/eas-cli/src/commands/build/internal.ts +++ b/packages/eas-cli/src/commands/build/internal.ts @@ -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 @@ -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({ diff --git a/packages/eas-cli/src/commands/project/onboarding.ts b/packages/eas-cli/src/commands/project/onboarding.ts index 7e854a8659..9d9d65719f 100644 --- a/packages/eas-cli/src/commands/project/onboarding.ts +++ b/packages/eas-cli/src/commands/project/onboarding.ts @@ -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({ diff --git a/packages/eas-cli/src/commands/submit/internal.ts b/packages/eas-cli/src/commands/submit/internal.ts index 7fae9e333a..17543f81bf 100644 --- a/packages/eas-cli/src/commands/submit/internal.ts +++ b/packages/eas-cli/src/commands/submit/internal.ts @@ -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. @@ -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, diff --git a/packages/eas-cli/src/vcs/clients/git.ts b/packages/eas-cli/src/vcs/clients/git.ts index 0521386f27..e52bbc6d0d 100644 --- a/packages/eas-cli/src/vcs/clients/git.ts +++ b/packages/eas-cli/src/vcs/clients/git.ts @@ -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'; @@ -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 { @@ -119,10 +126,6 @@ export default class GitClient extends Client { } } - public override async isCommitRequiredAsync(): Promise { - return await this.hasUncommittedChangesAsync(); - } - public override async showChangedFilesAsync(): Promise { const gitStatusOutput = await gitStatusAsync({ showUntracked: true, @@ -144,50 +147,80 @@ export default class GitClient extends Client { ).stdout.trim(); } + public override async isCommitRequiredAsync(): Promise { + if (!this.requireCommit) { + return false; + } + + return await this.hasUncommittedChangesAsync(); + } + public async makeShallowCopyAsync(destinationPath: string): Promise { - 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 { @@ -252,10 +285,24 @@ export default class GitClient extends Client { } public override async isFileIgnoredAsync(filePath: string): Promise { + 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; @@ -386,3 +433,51 @@ async function setGitCaseSensitivityAsync( }); } } + +async function assertEnablingGitCaseSensitivityDoesNotCauseNewUncommittedChangesAsync( + cwd: string +): Promise { + // 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); + } +} diff --git a/packages/eas-cli/src/vcs/clients/gitNoCommit.ts b/packages/eas-cli/src/vcs/clients/gitNoCommit.ts deleted file mode 100644 index 7b761fd8d9..0000000000 --- a/packages/eas-cli/src/vcs/clients/gitNoCommit.ts +++ /dev/null @@ -1,45 +0,0 @@ -import spawnAsync from '@expo/spawn-async'; -import chalk from 'chalk'; -import path from 'path'; - -import GitClient from './git'; -import Log from '../../log'; -import { Ignore, makeShallowCopyAsync } from '../local'; - -export default class GitNoCommitClient extends GitClient { - public override async isCommitRequiredAsync(): Promise { - return false; - } - - public override async getRootPathAsync(): Promise { - return (await spawnAsync('git', ['rev-parse', '--show-toplevel'])).stdout.trim(); - } - - public override async makeShallowCopyAsync(destinationPath: string): Promise { - // normalize converts C:/some/path to C:\some\path on windows - const srcPath = path.normalize(await this.getRootPathAsync()); - await makeShallowCopyAsync(srcPath, destinationPath); - } - - public override async isFileIgnoredAsync(filePath: string): Promise { - // normalize converts C:/some/path to C:\some\path on windows - const rootPath = path.normalize(await this.getRootPathAsync()); - const ignore = new Ignore(rootPath); - await ignore.initIgnoreAsync(); - return ignore.ignores(filePath); - } - - public override async trackFileAsync(file: string): Promise { - try { - await super.trackFileAsync(file); - } catch { - // In the no commit workflow it doesn't matter if we fail to track changes, - // so we can ignore if this throws an exception - Log.warn( - `Unable to track ${chalk.bold(path.basename(file))} in Git. Proceeding without tracking.` - ); - Log.warn(` Reason: the command ${chalk.bold(`"git add ${file}"`)} exited with an error.`); - Log.newLine(); - } - } -} diff --git a/packages/eas-cli/src/vcs/index.ts b/packages/eas-cli/src/vcs/index.ts index a65b1f5d1e..e81b662bc8 100644 --- a/packages/eas-cli/src/vcs/index.ts +++ b/packages/eas-cli/src/vcs/index.ts @@ -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'; @@ -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 }); } diff --git a/packages/eas-cli/src/vcs/local.ts b/packages/eas-cli/src/vcs/local.ts index 0100d94561..ea424fb293 100644 --- a/packages/eas-cli/src/vcs/local.ts +++ b/packages/eas-cli/src/vcs/local.ts @@ -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 = `