// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. import { createHash } from 'node:crypto'; import type * as fs from 'node:fs'; import * as path from 'node:path'; import { AlreadyExistsBehavior, FileSystem, Async } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { Constants } from '../utilities/Constants'; import { asAbsoluteFileSelectionSpecifier, getFileSelectionSpecifierPathsAsync, type IFileSelectionSpecifier } from './FileGlobSpecifier'; import type { HeftConfiguration } from '../configuration/HeftConfiguration'; import type { IHeftTaskPlugin } from '../pluginFramework/IHeftPlugin'; import type { IHeftTaskSession, IHeftTaskFileOperations } from '../pluginFramework/HeftTaskSession'; import type { WatchFileSystemAdapter } from '../utilities/WatchFileSystemAdapter'; import { type IIncrementalBuildInfo, makePathRelative, tryReadBuildInfoAsync, writeBuildInfoAsync } from '../pluginFramework/IncrementalBuildInfo'; /** * Used to specify a selection of files to copy from a specific source folder to one * or more destination folders. * * @public */ export interface ICopyOperation extends IFileSelectionSpecifier { /** * Absolute paths to folders which files or folders should be copied to. */ destinationFolders: string[]; /** * Copy only the file and discard the relative path from the source folder. */ flatten?: boolean; /** * Hardlink files instead of copying. * * @remarks * If the sourcePath is a folder, the contained directory structure will be re-created * and all files will be individually hardlinked. This means that folders will be new * filesystem entities and will have separate folder metadata, while the contained files * will maintain normal hardlink behavior. This is done since folders do not have a * cross-platform equivalent of a hardlink, and since file symlinks provide fundamentally * different functionality in comparison to hardlinks. */ hardlink?: boolean; } /** * Used to specify a selection of files to copy from a specific source folder to one * or more destination folders. * * @public */ export interface IIncrementalCopyOperation extends ICopyOperation { /** * If true, the file will be copied only if the source file is contained in the * IHeftTaskRunIncrementalHookOptions.changedFiles map. */ onlyIfChanged?: boolean; } interface ICopyFilesPluginOptions { copyOperations: ICopyOperation[]; } interface ICopyDescriptor { sourcePath: string; destinationPath: string; hardlink: boolean; } export function asAbsoluteCopyOperation( rootFolderPath: string, copyOperation: ICopyOperation ): ICopyOperation { const absoluteCopyOperation: ICopyOperation = asAbsoluteFileSelectionSpecifier( rootFolderPath, copyOperation ); absoluteCopyOperation.destinationFolders = copyOperation.destinationFolders.map((folder) => path.resolve(rootFolderPath, folder) ); return absoluteCopyOperation; } export function asRelativeCopyOperation( rootFolderPath: string, copyOperation: ICopyOperation ): ICopyOperation { return { ...copyOperation, destinationFolders: copyOperation.destinationFolders.map((folder) => makePathRelative(folder, rootFolderPath) ), sourcePath: copyOperation.sourcePath && makePathRelative(copyOperation.sourcePath, rootFolderPath) }; } export async function copyFilesAsync( copyOperations: Iterable, terminal: ITerminal, buildInfoPath: string, configHash: string, watchFileSystemAdapter?: WatchFileSystemAdapter ): Promise { const copyDescriptorByDestination: Map = await _getCopyDescriptorsAsync( copyOperations, watchFileSystemAdapter ); await _copyFilesInnerAsync(copyDescriptorByDestination, configHash, buildInfoPath, terminal); } async function _getCopyDescriptorsAsync( copyConfigurations: Iterable, fileSystemAdapter: WatchFileSystemAdapter | undefined ): Promise> { // Create a map to deduplicate and prevent double-writes // resolvedDestinationFilePath -> descriptor const copyDescriptorByDestination: Map = new Map(); await Async.forEachAsync( copyConfigurations, async (copyConfiguration: ICopyOperation) => { // "sourcePath" is required to be a folder. To copy a single file, put the parent folder in "sourcePath" // and the filename in "includeGlobs". const sourceFolder: string = copyConfiguration.sourcePath!; const sourceFiles: Map = await getFileSelectionSpecifierPathsAsync({ fileGlobSpecifier: copyConfiguration, fileSystemAdapter }); // Dedupe and throw if a double-write is detected for (const destinationFolderPath of copyConfiguration.destinationFolders) { // We only need to care about the keys of the map since we know all the keys are paths to files for (const sourceFilePath of sourceFiles.keys()) { // Only include the relative path from the sourceFolder if flatten is false const resolvedDestinationPath: string = path.resolve( destinationFolderPath, copyConfiguration.flatten ? path.basename(sourceFilePath) : path.relative(sourceFolder, sourceFilePath) ); // Throw if a duplicate copy target with a different source or options is specified const existingDestinationCopyDescriptor: ICopyDescriptor | undefined = copyDescriptorByDestination.get(resolvedDestinationPath); if (existingDestinationCopyDescriptor) { if ( existingDestinationCopyDescriptor.sourcePath === sourceFilePath && existingDestinationCopyDescriptor.hardlink === !!copyConfiguration.hardlink ) { // Found a duplicate, avoid adding again continue; } throw new Error( `Cannot copy multiple files to the same destination "${resolvedDestinationPath}".` ); } // Finally, default hardlink to false, add to the result, and add to the map for deduping const processedCopyDescriptor: ICopyDescriptor = { sourcePath: sourceFilePath, destinationPath: resolvedDestinationPath, hardlink: !!copyConfiguration.hardlink }; copyDescriptorByDestination.set(resolvedDestinationPath, processedCopyDescriptor); } } }, { concurrency: Constants.maxParallelism } ); return copyDescriptorByDestination; } async function _copyFilesInnerAsync( copyDescriptors: Map, configHash: string, buildInfoPath: string, terminal: ITerminal ): Promise { if (copyDescriptors.size === 0) { return; } let oldBuildInfo: IIncrementalBuildInfo | undefined = await tryReadBuildInfoAsync(buildInfoPath); if (oldBuildInfo && oldBuildInfo.configHash !== configHash) { terminal.writeVerboseLine(`File copy configuration changed, discarding incremental state.`); oldBuildInfo = undefined; } // Since in watch mode only changed files will get passed in, need to ensure that all files from // the previous build are still tracked. const inputFileVersions: Map = new Map(oldBuildInfo?.inputFileVersions); const buildInfo: IIncrementalBuildInfo = { configHash, inputFileVersions }; const allInputFiles: Set = new Set(); for (const copyDescriptor of copyDescriptors.values()) { allInputFiles.add(copyDescriptor.sourcePath); } await Async.forEachAsync( allInputFiles, async (inputFilePath: string) => { const fileContent: Buffer = await FileSystem.readFileToBufferAsync(inputFilePath); const fileHash: string = createHash('sha256').update(fileContent).digest('base64'); inputFileVersions.set(inputFilePath, fileHash); }, { concurrency: Constants.maxParallelism } ); const copyDescriptorsWithWork: ICopyDescriptor[] = []; for (const copyDescriptor of copyDescriptors.values()) { const { sourcePath } = copyDescriptor; const sourceFileHash: string | undefined = inputFileVersions.get(sourcePath); if (!sourceFileHash) { throw new Error(`Missing hash for input file: ${sourcePath}`); } if (oldBuildInfo?.inputFileVersions.get(sourcePath) === sourceFileHash) { continue; } copyDescriptorsWithWork.push(copyDescriptor); } if (copyDescriptorsWithWork.length === 0) { terminal.writeLine('All requested file copy operations are up to date. Nothing to do.'); return; } let copiedFileCount: number = 0; let linkedFileCount: number = 0; await Async.forEachAsync( copyDescriptorsWithWork, async (copyDescriptor: ICopyDescriptor) => { if (copyDescriptor.hardlink) { linkedFileCount++; await FileSystem.createHardLinkAsync({ linkTargetPath: copyDescriptor.sourcePath, newLinkPath: copyDescriptor.destinationPath, alreadyExistsBehavior: AlreadyExistsBehavior.Overwrite }); terminal.writeVerboseLine( `Linked "${copyDescriptor.sourcePath}" to "${copyDescriptor.destinationPath}".` ); } else { copiedFileCount++; await FileSystem.copyFilesAsync({ sourcePath: copyDescriptor.sourcePath, destinationPath: copyDescriptor.destinationPath, alreadyExistsBehavior: AlreadyExistsBehavior.Overwrite }); terminal.writeVerboseLine( `Copied "${copyDescriptor.sourcePath}" to "${copyDescriptor.destinationPath}".` ); } }, { concurrency: Constants.maxParallelism } ); terminal.writeLine( `Copied ${copiedFileCount} file${copiedFileCount === 1 ? '' : 's'} and ` + `linked ${linkedFileCount} file${linkedFileCount === 1 ? '' : 's'}` ); await writeBuildInfoAsync(buildInfo, buildInfoPath); } const PLUGIN_NAME: 'copy-files-plugin' = 'copy-files-plugin'; export default class CopyFilesPlugin implements IHeftTaskPlugin { public apply( taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration, pluginOptions: ICopyFilesPluginOptions ): void { taskSession.hooks.registerFileOperations.tap( PLUGIN_NAME, (operations: IHeftTaskFileOperations): IHeftTaskFileOperations => { for (const operation of pluginOptions.copyOperations) { operations.copyOperations.add(operation); } return operations; } ); } }