import spawnAsync from '@expo/spawn-async'; import fs from 'fs/promises'; import { glob } from 'glob'; import createIgnore, { Ignore as SingleFileIgnore } from 'ignore'; import path from 'path'; export interface Client { getRootPathAsync(): Promise; isFileIgnoredAsync(filePath: string): Promise; } export default async function getVCSClientAsync(projectDir: string): Promise { if (await isGitInstalledAndConfiguredAsync()) { return new GitClient(); } else { return new NoVCSClient(projectDir); } } class GitClient implements Client { public async getRootPathAsync(): Promise { return (await spawnAsync('git', ['rev-parse', '--show-toplevel'])).stdout.trim(); } async isFileIgnoredAsync(filePath: string): Promise { try { await spawnAsync('git', ['check-ignore', '-q', filePath], { cwd: path.normalize(await this.getRootPathAsync()), }); return true; } catch { return false; } } } class NoVCSClient implements Client { constructor(private readonly projectDir: string) {} async getRootPathAsync(): Promise { return this.projectDir; } async isFileIgnoredAsync(filePath: string): Promise { const ignore = new Ignore(this.projectDir); await ignore.initIgnoreAsync(); return ignore.ignores(filePath); } } async function isGitInstalledAndConfiguredAsync(): Promise { try { await spawnAsync('git', ['--help']); } catch (error: any) { if (error.code === 'ENOENT') { return false; } throw error; } try { await spawnAsync('git', ['rev-parse', '--show-toplevel']); } catch { return false; } return true; } const GITIGNORE_FILENAME = '.gitignore'; const DEFAULT_IGNORE = ` .git node_modules `; /** * Ignore wraps the 'ignore' package to support multiple .gitignore files * in subdirectories. * * Inconsistencies with git behavior: * - if parent .gitignore has ignore rule and child has exception to that rule, * file will still be ignored, * - node_modules is always ignored * * Differs from the eas-cli Ignore class by not using `.easignore`. Otherwise this is copied. May try * to merge the implementations soon. */ class Ignore { private ignoreMapping: (readonly [string, SingleFileIgnore])[] = []; constructor(private rootDir: string) {} public async initIgnoreAsync(): Promise { const ignoreFilePaths = ( await glob(`**/${GITIGNORE_FILENAME}`, { cwd: this.rootDir, ignore: ['node_modules'], follow: false, }) ) // ensure that parent dir is before child directories .sort((a, b) => a.length - b.length && a.localeCompare(b)); const ignoreMapping = await Promise.all( ignoreFilePaths.map(async (filePath) => { return [ filePath.slice(0, filePath.length - GITIGNORE_FILENAME.length), createIgnore().add(await fs.readFile(path.join(this.rootDir, filePath), 'utf-8')), ] as const; }) ); this.ignoreMapping = [['', createIgnore().add(DEFAULT_IGNORE)], ...ignoreMapping]; } public ignores(relativePath: string): boolean { for (const [prefix, ignore] of this.ignoreMapping) { if (relativePath.startsWith(prefix) && ignore.ignores(relativePath.slice(prefix.length))) { return true; } } return false; } }