| /** |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you under the Apache License, Version 2.0 (the |
| "License"); you may not use this file except in compliance |
| with the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, |
| software distributed under the License is distributed on an |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| KIND, either express or implied. See the License for the |
| specific language governing permissions and limitations |
| under the License. |
| */ |
| |
| 'use strict'; |
| |
| const fs = require('fs-extra'); |
| const path = require('path'); |
| const fastGlob = require('fast-glob'); |
| |
| /** |
| * Logging callback used in the FileUpdater methods. |
| * @callback loggingCallback |
| * @param {string} message A message describing a single file update operation. |
| */ |
| |
| /** |
| * Updates a target file or directory with a source file or directory. (Directory updates are |
| * not recursive.) Stats for target and source items must be passed in. This is an internal |
| * helper function used by other methods in this module. |
| * |
| * @param {?string} sourcePath Source file or directory to be used to update the |
| * destination. If the source is null, then the destination is deleted if it exists. |
| * @param {?fs.Stats} sourceStats An instance of fs.Stats for the source path, or null if |
| * the source does not exist. |
| * @param {string} targetPath Required destination file or directory to be updated. If it does |
| * not exist, it will be created. |
| * @param {?fs.Stats} targetStats An instance of fs.Stats for the target path, or null if |
| * the target does not exist. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function updatePathWithStats (sourcePath, sourceStats, targetPath, targetStats, options, log = () => {}) { |
| const rootDir = (options && options.rootDir) || ''; |
| const copyAll = (options && options.all) || false; |
| const targetFullPath = path.join(rootDir, targetPath); |
| |
| // Source or target could be a device, socket or pipe. We just skip these. |
| const isSpecial = stats => stats && !stats.isFile() && !stats.isDirectory(); |
| if (isSpecial(targetStats) || isSpecial(sourceStats)) return false; |
| |
| if (!sourceStats) { |
| if (!targetStats) return false; |
| |
| // The target exists but the source not, so we delete the target. |
| log(`delete ${targetPath} (no source)`); |
| fs.removeSync(targetFullPath); |
| return true; |
| } |
| |
| if (targetStats && (targetStats.isDirectory() !== sourceStats.isDirectory())) { |
| // The target exists but the directory status doesn't match the source. |
| // So we delete it and let it be created again by the code below. |
| log(`delete ${targetPath} (wrong type)`); |
| fs.removeSync(targetFullPath); |
| targetStats = null; |
| } |
| |
| if (sourceStats.isDirectory() && !targetStats) { |
| // The target directory does not exist, so we create it. |
| log(`mkdir ${targetPath}`); |
| fs.ensureDirSync(targetFullPath); |
| return true; |
| } |
| |
| if (sourceStats.isFile()) { |
| // The source is a file and the target either is one too or missing. |
| |
| // If the caller did not specify that all files should be copied, check |
| // if the source has been modified since it was copied to the target, or |
| // if the file sizes are different. (The latter catches most cases in |
| // which something was done to the file after copying.) Comparison is >= |
| // rather than > to allow for timestamps lacking sub-second precision in |
| // some filesystems. |
| const needsUpdate = !targetStats || copyAll || |
| sourceStats.size !== targetStats.size || |
| sourceStats.mtime.getTime() >= targetStats.mtime.getTime(); |
| if (!needsUpdate) return false; |
| |
| const type = targetStats ? 'updated' : 'new'; |
| log(`copy ${sourcePath} ${targetPath} (${type} file)`); |
| fs.copySync(path.join(rootDir, sourcePath), targetFullPath); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Helper for updatePath and updatePaths functions. Queries stats for source and target |
| */ |
| function updatePathInternal (sourcePath, targetPath, options, log) { |
| const rootDir = (options && options.rootDir) || ''; |
| const targetFullPath = path.join(rootDir, targetPath); |
| const targetStats = fs.existsSync(targetFullPath) ? fs.statSync(targetFullPath) : null; |
| let sourceStats = null; |
| |
| if (sourcePath) { |
| // A non-null source path was specified. It should exist. |
| const sourceFullPath = path.join(rootDir, sourcePath); |
| if (!fs.existsSync(sourceFullPath)) { |
| throw new Error(`Source path does not exist: ${sourcePath}`); |
| } |
| |
| sourceStats = fs.statSync(sourceFullPath); |
| } |
| |
| return updatePathWithStats(sourcePath, sourceStats, targetPath, targetStats, options, log); |
| } |
| |
| /** |
| * Updates a target file or directory with a source file or directory. (Directory updates are |
| * not recursive.) |
| * |
| * @param {?string} sourcePath Source file or directory to be used to update the |
| * destination. If the source is null, then the destination is deleted if it exists. |
| * @param {string} targetPath Required destination file or directory to be updated. If it does |
| * not exist, it will be created. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function updatePath (sourcePath, targetPath, options, log) { |
| if (sourcePath !== null && typeof sourcePath !== 'string') { |
| throw new Error('A source path (or null) is required.'); |
| } |
| |
| if (!targetPath || typeof targetPath !== 'string') { |
| throw new Error('A target path is required.'); |
| } |
| |
| return updatePathInternal(sourcePath, targetPath, options, log); |
| } |
| |
| /** |
| * Updates files and directories based on a mapping from target paths to source paths. Targets |
| * with null sources in the map are deleted. |
| * |
| * @param {Object} pathMap A dictionary mapping from target paths to source paths. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function updatePaths (pathMap, options, log) { |
| if (!pathMap || typeof pathMap !== 'object' || Array.isArray(pathMap)) { |
| throw new Error('An object mapping from target paths to source paths is required.'); |
| } |
| |
| // Iterate in sorted order for nicer logs |
| return Object.keys(pathMap).sort().map(targetPath => { |
| const sourcePath = pathMap[targetPath]; |
| return updatePathInternal(sourcePath, targetPath, options, log); |
| }).some(updated => updated); |
| } |
| |
| /** |
| * Updates a target directory with merged files and subdirectories from source directories. |
| * |
| * @param {string|string[]} sourceDirs Required source directory or array of source directories |
| * to be merged into the target. The directories are listed in order of precedence; files in |
| * directories later in the array supersede files in directories earlier in the array |
| * (regardless of timestamps). |
| * @param {string} targetDir Required destination directory to be updated. If it does not exist, |
| * it will be created. If it exists, newer files from source directories will be copied over, |
| * and files missing in the source directories will be deleted. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {string|string[]} [options.include] Optional glob string or array of glob strings that |
| * are tested against both target and source relative paths to determine if they are included |
| * in the merge-and-update. If unspecified, all items are included. |
| * @param {string|string[]} [options.exclude] Optional glob string or array of glob strings that |
| * are tested against both target and source relative paths to determine if they are excluded |
| * from the merge-and-update. Exclusions override inclusions. If unspecified, no items are |
| * excluded. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function mergeAndUpdateDir (sourceDirs, targetDir, options, log) { |
| if (sourceDirs && typeof sourceDirs === 'string') { |
| sourceDirs = [sourceDirs]; |
| } else if (!Array.isArray(sourceDirs)) { |
| throw new Error('A source directory path or array of paths is required.'); |
| } |
| |
| if (!targetDir || typeof targetDir !== 'string') { |
| throw new Error('A target directory path is required.'); |
| } |
| |
| const rootDir = (options && options.rootDir) || ''; |
| |
| let include = (options && options.include) || ['**']; |
| if (typeof include === 'string') { |
| include = [include]; |
| } else if (!Array.isArray(include)) { |
| throw new Error('Include parameter must be a glob string or array of glob strings.'); |
| } |
| |
| let exclude = (options && options.exclude) || []; |
| if (typeof exclude === 'string') { |
| exclude = [exclude]; |
| } else if (!Array.isArray(exclude)) { |
| throw new Error('Exclude parameter must be a glob string or array of glob strings.'); |
| } |
| |
| // Scan the files in each of the source directories. |
| const sourceMaps = sourceDirs.map(sourceDir => { |
| const sourcePath = path.join(rootDir, sourceDir); |
| if (!fs.existsSync(sourcePath)) { |
| throw new Error(`Source directory does not exist: ${sourcePath}`); |
| } |
| |
| return mapDirectory(rootDir, sourceDir, include, exclude); |
| }); |
| |
| // Scan the files in the target directory, if it exists. |
| const targetFullPath = path.join(rootDir, targetDir); |
| const targetMap = fs.existsSync(targetFullPath) |
| ? mapDirectory(rootDir, targetDir, include, exclude) |
| : {}; |
| const pathMap = mergePathMaps(sourceMaps, targetMap, targetDir); |
| |
| // Iterate in sorted order for nicer logs |
| return Object.keys(pathMap).sort().map(subPath => { |
| const entry = pathMap[subPath]; |
| return updatePathWithStats( |
| entry.sourcePath, |
| entry.sourceStats, |
| entry.targetPath, |
| entry.targetStats, |
| options, |
| log |
| ); |
| }).some(updated => updated); |
| } |
| |
| /** |
| * Creates a dictionary map of all files and directories under a path. |
| */ |
| function mapDirectory (rootDir, subDir, include, exclude) { |
| const pathToMap = path.join(rootDir, subDir); |
| |
| return fastGlob.sync(include, { |
| fs, // we pass in fs here, to be able to mock it in our tests |
| dot: true, |
| stats: true, |
| onlyFiles: false, |
| cwd: pathToMap, |
| ignore: exclude |
| }) |
| .map(({ path: p, stats }) => ({ |
| [path.normalize(p)]: { subDir, stats } |
| })) |
| .reduce( |
| (dirMap, fragment) => Object.assign(dirMap, fragment), |
| { '': { subDir, stats: fs.statSync(pathToMap) } } |
| ); |
| } |
| |
| /** |
| * Merges together multiple source maps and a target map into a single mapping from |
| * relative paths to objects with target and source paths and stats. |
| */ |
| function mergePathMaps (sourceMaps, targetMap, targetDir) { |
| // Merge multiple source maps together, along with target path info. |
| // Entries in later source maps override those in earlier source maps. |
| const sourceMap = Object.assign({}, ...sourceMaps); |
| |
| const allKeys = [].concat(Object.keys(sourceMap), Object.keys(targetMap)); |
| const pathMap = allKeys.reduce((acc, subPath) => ( |
| Object.assign(acc, { |
| [subPath]: { |
| targetPath: path.join(targetDir, subPath), |
| targetStats: null, |
| sourcePath: null, |
| sourceStats: null |
| } |
| }) |
| ), {}); |
| |
| Object.entries(sourceMap).forEach(([subPath, { subDir, stats }]) => { |
| Object.assign( |
| pathMap[subPath], |
| { sourcePath: path.join(subDir, subPath), sourceStats: stats } |
| ); |
| }); |
| |
| // Fill in target stats for targets that exist, and create entries |
| // for targets that don't have any corresponding sources. |
| Object.entries(targetMap).forEach(([subPath, { stats }]) => { |
| Object.assign(pathMap[subPath], { targetStats: stats }); |
| }); |
| |
| return pathMap; |
| } |
| |
| module.exports = { |
| updatePath, |
| updatePaths, |
| mergeAndUpdateDir |
| }; |