import * as path from 'path'; import * as child_process from 'child_process'; import { inject } from 'inversify'; import { Service } from '../decorator'; import { LocalState, LocalStateUpdateAction } from '../logic/LocalState'; import { type ISelection, SparoProfile } from '../logic/SparoProfile'; import { GitService } from './GitService'; import { SparoProfileService } from './SparoProfileService'; import { TerminalService } from './TerminalService'; import { Executable, FileSystem, JsonFile, JsonSyntax } from '@rushstack/node-core-library'; import { Stopwatch } from '../logic/Stopwatch'; export interface IRushSparseCheckoutOptions { selections?: ISelection[]; includeFolders?: string[]; excludeFolders?: string[]; to?: string[]; from?: string[]; checkoutAction?: 'add' | 'set' | 'purge' | 'skeleton'; } export interface IRushProject { packageName: string; projectFolder: string; } export interface IResolveSparoProfileOptions { localStateUpdateAction: LocalStateUpdateAction; } @Service() export class GitSparseCheckoutService { @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; @inject(GitService) private _gitService!: GitService; @inject(LocalState) private _localState!: LocalState; @inject(TerminalService) private _terminalService!: TerminalService; private _rushConfigLoaded: boolean = false; private _rushProjects: IRushProject[] = []; private _packageNames: Set = new Set(); public initializeRepository(): void { if ('true' !== this._gitService.getGitConfig('core.sparsecheckout')?.trim()) { throw new Error('Sparse checkout is not enabled in this repo.'); } this._loadRushConfiguration(); this._prepareMonorepoSkeleton(); } public async resolveSparoProfileAsync( profile: string, options: IResolveSparoProfileOptions ): Promise<{ selections: ISelection[]; includeFolders: string[]; excludeFolders: string[]; }> { this.initializeRepository(); const sparoProfile: SparoProfile | undefined = await this._sparoProfileService.getProfileAsync(profile); if (!sparoProfile) { const availableProfiles: string[] = Array.from( (await this._sparoProfileService.getProfilesAsync()).keys() ); throw new Error( `Parse sparse profile "${profile}" error. ${ availableProfiles.length !== 0 ? `Available profiles are: ${availableProfiles.join(',')} ` : 'No profiles now' }` ); } const repositoryRoot: string | null = this._gitService.getRepoInfo().root; if (!repositoryRoot) { throw new Error(`Running outside of the git repository folder`); } const { selections, includeFolders, excludeFolders } = sparoProfile; const { localStateUpdateAction } = options; await this._localState.setProfiles( { [profile]: { selections, includeFolders, excludeFolders } }, localStateUpdateAction ); return { selections, includeFolders, excludeFolders }; } public async checkoutSkeletonAsync(): Promise { await this._rushSparseCheckoutAsync({ checkoutAction: 'skeleton' }); } public async checkoutAsync({ to, from, selections, includeFolders, excludeFolders }: Pick< IRushSparseCheckoutOptions, 'to' | 'from' | 'selections' | 'includeFolders' | 'excludeFolders' >): Promise { await this._rushSparseCheckoutAsync({ to, from, selections, includeFolders, excludeFolders }); } public async purgeAsync(): Promise { await this._rushSparseCheckoutAsync({ checkoutAction: 'purge' }); } private async _rushSparseCheckoutAsync(options: IRushSparseCheckoutOptions): Promise { const { to, from, selections = [], includeFolders = [], excludeFolders = [], checkoutAction = 'add' } = options; const { terminal } = this._terminalService; // Check git repo if ( 'true' !== this._gitService .executeGitCommandAndCaptureOutput({ args: ['rev-parse', '--is-inside-work-tree'] }) .trim() ) { throw new Error(`git repo not found. You should run this tool inside a git repo`); } { const stopwatch: Stopwatch = Stopwatch.start(); this.initializeRepository(); terminal.writeLine('Initialize repo sparse checkout. (%s)', stopwatch.toString()); stopwatch.stop(); } const fromSelectors: Set = new Set(); const toSelectors: Set = new Set(); if ('to' in options && to) { to.forEach((x) => { toSelectors.add(x); }); } if ('from' in options && from) { from.forEach((x) => { fromSelectors.add(x); }); } for (const selection of selections) { switch (selection.selector) { case '--to': { toSelectors.add(selection.argument); break; } case '--from': { fromSelectors.add(selection.argument); break; } default: { terminal.writeErrorLine(`Error, unknown selector ${selection.selector}`); break; } } } const unfoundedPackages: string[] = []; for (const selector of [...toSelectors, ...fromSelectors]) { if (selector.indexOf(':') < 0) { const packageName: string = selector; const result: string | undefined = this._findProjectByShorthandName(packageName); if (!result) { unfoundedPackages.push(packageName); } } } if (unfoundedPackages.length > 0) { throw new Error(`These packages: ${unfoundedPackages.join(', ')} does not exist in rush.json`); } let targetFolders: string[] = []; if (toSelectors.size !== 0 || fromSelectors.size !== 0) { const stopwatch: Stopwatch = Stopwatch.start(); targetFolders = this._getTargetFoldersByRushList({ toSelectors, fromSelectors }); terminal.writeLine('Run rush list command. (%s)', stopwatch.toString()); stopwatch.stop(); } else { terminal.writeDebugLine('Skip rush list regarding the absence of from selectors and to selectors'); } // include rule targetFolders.push(...includeFolders); // exclude rule, overrides everything const excludeSet: Set = new Set(excludeFolders); targetFolders = targetFolders.filter((folder) => !excludeSet.has(folder)); { const stopwatch: Stopwatch = Stopwatch.start(); if (checkoutAction === 'purge') { // re-apply the initial paths for setting up sparse repo state this._prepareMonorepoSkeleton({ restore: true }); } // FIXME: too long CLI parameter if (!(checkoutAction === 'purge' || checkoutAction === 'skeleton')) { if (targetFolders.length === 0) { terminal.writeDebugLine(`Skip sparse checkout regarding no target folders`); } else { terminal.writeLine(`Run sparse checkout for these folders: ${targetFolders.join(' ')}`); this._sparseCheckoutPaths(targetFolders, { action: 'add' }); } } terminal.writeLine('Sparse checkout target folders. (%s)', stopwatch.toString()); stopwatch.stop(); } } private _sparseCheckoutPaths(paths: string[], options: { action: 'set' | 'add' }): void { const { action } = options; const args: string[] = ['sparse-checkout', action].concat(paths); this._gitService.executeGitCommand({ args }); } private _loadRushConfiguration(): void { if (this._rushConfigLoaded) { return; } const monorepoRoot: string = this._gitService.getRepoInfo().root; const rushJsonPath: string = path.resolve(monorepoRoot, 'rush.json'); if (!FileSystem.exists(rushJsonPath)) { throw new Error('Missing rush.json. Do you work in a Rush.js monorepo?'); } const { projects } = JsonFile.load(rushJsonPath, { jsonSyntax: JsonSyntax.JsonWithComments }) as { projects: IRushProject[]; }; this._rushConfigLoaded = true; projects.forEach((project) => { this._rushProjects.push(project); this._packageNames.add(project.packageName); }); } private _prepareMonorepoSkeleton(options: { restore?: boolean } = {}): void { const { restore } = options; const finalSkeletonPaths: string[] = this._getSkeletonPaths(); this._terminalService.terminal.writeLine('Initializing skeleton folders, files of package.json'); this._sparseCheckoutPaths(finalSkeletonPaths, { action: restore ? 'set' : 'add' }); } private _getSkeletonPaths(): string[] { const basicFolders: string[] = ['.vscode', 'common', 'common/sparse-profiles', 'scripts', 'plugins']; this._sparseCheckoutPaths(basicFolders, { action: 'add' }); const pluginPaths: string[] = this._findRushPluginsPaths(); // prepare all package.json for cone mode const packageJSON: string[] = []; for (const project of this._rushProjects) { const curPath: string = `${project.projectFolder}/_`; packageJSON.push(curPath); } return basicFolders.concat(pluginPaths, packageJSON); } private _findRushPluginsPaths(): string[] { const autoInstallerPath: string = path.resolve('common', 'autoinstallers'); const ignoreNames: Set = new Set(['node_modules']); const rushPluginPaths: string[] = []; if (!FileSystem.getStatistics(autoInstallerPath).isDirectory()) { throw new Error( `A non-directory path was put into the rush plugin scan process, please contact tool author` ); } const dfsScan = (folderName: string): void => { if (!FileSystem.getStatistics(folderName).isDirectory()) { return; } const pathsUnderThisFolder: string[] = FileSystem.readFolderItemNames(folderName); for (const pathUnder of pathsUnderThisFolder) { if (ignoreNames.has(pathUnder)) continue; else if (pathUnder === 'package.json') { const { dependencies = {} } = JsonFile.load(path.resolve(folderName, pathUnder)) as { dependencies: { [key: string]: string }; }; for (const key of Object.keys(dependencies)) { const version: string = dependencies[key]; if (version.indexOf('link:') === -1) continue; const splitPaths: string[] = version .substring(version.indexOf('link:') + 'link:'.length) .split('/'); while (splitPaths.length && !/[A-Za-z]/.test(splitPaths[0][0])) splitPaths.shift(); rushPluginPaths.push(splitPaths.join('/')); } } else { dfsScan(path.resolve(autoInstallerPath, folderName, pathUnder)); } } }; dfsScan(autoInstallerPath); return rushPluginPaths; } private _findProjectByShorthandName(shorthandPackageName: string): string | undefined { const { _packageNames: packageNames } = this; if (packageNames.has(shorthandPackageName)) { return shorthandPackageName; } let result: string | undefined; for (const packageName of packageNames) { if (this._getUnscopedName(packageName) === shorthandPackageName) { if (result) { return undefined; } else { result = packageName; } } } return result; } private _getUnscopedName(packageName: string): string { if (packageName[0] === '@') { const indexOfScopeSlash: number = packageName.indexOf('/'); const unscopedName: string = packageName.slice(indexOfScopeSlash + 1); return unscopedName; } return packageName; } private _getTargetFoldersByRushList({ toSelectors, fromSelectors }: { toSelectors: Iterable; fromSelectors: Iterable; }): string[] { const { terminal } = this._terminalService; const args: string[] = ['list', '--json']; for (const toSelector of toSelectors) { args.push('--to'); args.push(toSelector); } for (const fromSelector of fromSelectors) { args.push('--from'); args.push(fromSelector); } terminal.writeVerboseLine(`Run command: rush ${args.join(' ')}`); const result: child_process.SpawnSyncReturns = Executable.spawnSync('rush', args, { stdio: ['pipe', 'pipe', 'pipe'] }); const processedResult: string = this._processListResult(result.stdout.toString()); terminal.writeVerboseLine(processedResult); const { projects: targetDeps } = JSON.parse(processedResult) as { projects: { path: string }[]; }; return targetDeps.map((targetDep) => targetDep.path); } private _processListResult(input: string): string { const stringList: string[] = input.split('\n'); let endOfInstallScript: number = -1; for (let i: number = 0; i < stringList.length; ++i) { if (stringList[i][0] === '{') { endOfInstallScript = i; break; } } const jsonStringList: string[] = stringList.slice(endOfInstallScript); return jsonStringList.join('\n'); } }