diff --git a/README.md b/README.md index e264ded5..e0bef530 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,7 @@ module.exports = { | [`ignore`](#ignore) | `{Array}` | `[]` | Array of globs to ignore (applied to `from`) | | [`context`](#context) | `{String}` | `compiler.options.context` | A path that determines how to interpret the `from` path, shared for all patterns | | [`copyUnmodified`](#copyunmodified) | `{Boolean}` | `false` | Copies files, regardless of modification when using watch or `webpack-dev-server`. All files are copied on first build, regardless of this option | +| [`keepTimes`](#keeptimes) | `{Boolean}` | `false` | Copy the original access and modification over to the destination files, when possible | #### `logLevel` @@ -568,6 +569,18 @@ module.exports = { }; ``` +#### `keepTimes` + +Attempt to copy source files' access and modification times over to the destination files. + +**webpack.config.js** + +```js +module.exports = { + plugins: [new CopyPlugin([...patterns], { keepTimes: true })], +}; +``` + ## Contributing Please take a moment to read our contributing guidelines if you haven't yet done so. diff --git a/src/index.js b/src/index.js index f72b4804..1012c0f5 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import schema from './options.json'; import preProcessPattern from './preProcessPattern'; import processPattern from './processPattern'; import postProcessPattern from './postProcessPattern'; +import updateTimes from './updateTimes'; class CopyPlugin { constructor(patterns = [], options = {}) { @@ -52,6 +53,7 @@ class CopyPlugin { output: compiler.options.output.path, ignore: this.options.ignore || [], copyUnmodified: this.options.copyUnmodified, + keepTimes: this.options.keepTimes, concurrency: this.options.concurrency, }; @@ -117,6 +119,10 @@ class CopyPlugin { } } + if (this.options.keepTimes) { + updateTimes(compiler, compilation, logger); + } + logger.debug('finishing after-emit'); callback(); diff --git a/src/options.json b/src/options.json index 90c465d6..ffc594e8 100644 --- a/src/options.json +++ b/src/options.json @@ -67,6 +67,9 @@ }, "transformPath": { "instanceof": "Function" + }, + "keepTimes": { + "type": "boolean" } }, "required": ["from"] diff --git a/src/postProcessPattern.js b/src/postProcessPattern.js index 638804be..12ab51ce 100644 --- a/src/postProcessPattern.js +++ b/src/postProcessPattern.js @@ -197,6 +197,10 @@ export default function postProcessPattern(globalRef, pattern, file) { source() { return content; }, + copyPluginTimes: { + atime: stats.atime, + mtime: stats.mtime, + }, }; }); }); diff --git a/src/updateTimes.js b/src/updateTimes.js new file mode 100644 index 00000000..2887c5ff --- /dev/null +++ b/src/updateTimes.js @@ -0,0 +1,57 @@ +/** + * Attempt to get an Utimes function for the compiler's output filesystem. + */ +function getUtimesFunction(compiler) { + if (compiler.outputFileSystem.utimes) { + // Webpack 5+ on Node will use graceful-fs for outputFileSystem so utimes is always there. + // Other custom outputFileSystems could also have utimes. + return compiler.outputFileSystem.utimes.bind(compiler.outputFileSystem); + } else if ( + compiler.outputFileSystem.constructor && + compiler.outputFileSystem.constructor.name === 'NodeOutputFileSystem' + ) { + // Default NodeOutputFileSystem can just use fs.utimes, but we need to late-import it in case + // we're running in a web context and statically importing `fs` might be a bad idea. + // eslint-disable-next-line global-require + return require('fs').utimes; + } + return null; +} + +/** + * Update the times of disk files for which we have recorded a source time + * @param compiler + * @param compilation + * @param logger + */ +function updateTimes(compiler, compilation, logger) { + const utimes = getUtimesFunction(compiler); + let nUpdated = 0; + for (const [name, asset] of Object.entries(compilation.assets)) { + // eslint-disable-next-line no-underscore-dangle + const times = asset.copyPluginTimes; + if (times) { + const targetPath = + asset.existsAt || + compiler.outputFileSystem.join(compiler.outputPath, name); + if (!utimes) { + logger.warn( + `unable to update time for ${targetPath} using current file system` + ); + } else { + // TODO: process these errors in a better way and/or wait for completion? + utimes(targetPath, times.atime, times.mtime, (err) => { + if (err) { + logger.warn(`${targetPath}: utimes: ${err}`); + } + }); + nUpdated += 1; + } + } + } + if (nUpdated > 0) { + logger.info(`times updated for ${nUpdated} copied files`); + } +} + +export default updateTimes; diff --git a/test/CopyPlugin.test.js b/test/CopyPlugin.test.js index 8405a89e..e6a73655 100644 --- a/test/CopyPlugin.test.js +++ b/test/CopyPlugin.test.js @@ -1,4 +1,5 @@ import path from 'path'; +import fs from 'fs'; import { MockCompiler } from './helpers/mocks'; import { run, runEmit, runChange } from './helpers/run'; @@ -250,6 +251,38 @@ describe('apply function', () => { .then(done) .catch(done); }); + + it('should copy file modification times when told to', (done) => { + const origStat = fs.statSync(path.join(FIXTURES_DIR, 'file.txt')); + const utimeCalls = {}; + const compiler = new MockCompiler(); + // Patch in some things that are missing by default... + compiler.outputFileSystem.join = (a, b) => path.join(a || '', b); + compiler.outputFileSystem.utimes = (pth, atime, mtime, callback) => { + utimeCalls[pth] = { atime, mtime }; + callback(null); + }; + + runEmit({ + compiler, + expectedAssetKeys: ['file.txt'], + options: { + keepTimes: true, + }, + patterns: [ + { + from: 'file.txt', + }, + ], + }) + .then(() => { + const { atime, mtime } = utimeCalls['file.txt']; + expect(atime).toEqual(origStat.atime); + expect(mtime).toEqual(origStat.mtime); + }) + .then(done) + .catch(done); + }); }); describe('difference path segment separation', () => {