diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07e6e47 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules diff --git a/index.js b/index.js new file mode 100644 index 0000000..7b017f6 --- /dev/null +++ b/index.js @@ -0,0 +1,152 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const globby = require('globby'); +const gutil = require('gulp-util'); +const Transform = require('stream').Transform; + +function verify(condition, message) { + if (!condition) { + throw new gutil.PluginError('gulp-prune', message); + } +} + +function normalize(file) { + if (process.platform === 'win32') { + file = file.replace(/\\/g, '/'); + } + return file; +} + +// The mapping function converts source name to one or more destination paths. +function getMapper(options) { + if (options.map !== undefined) { + verify(typeof options.map === 'function', 'options.map must be a function'); + verify(options.ext === undefined, 'options.map and options.ext are exclusive'); + return options.map; + } else if (typeof options.ext === 'string') { + let mapExt = options.ext; + return (name) => name.replace(/(\.[^.]*)?$/, mapExt); + } else if (options.ext !== undefined) { + verify(options.ext instanceof Array && options.ext.every(e => typeof e === 'string'), 'options.ext must be a string or string[]'); + let mapExtList = options.ext.slice(); + return (name) => mapExtList.map(e => name.replace(/(\.[^.]*)?$/, e)); + } else { + return (name) => name; + } +} + +// The delete pattern is a minimatch pattern used to find files in the dest directory. +function getDeletePattern(options) { + if (typeof options.filter === 'string') { + return options.filter; + } else { + verify(options.filter === undefined || typeof options.filter === 'function', + 'options.filter must be a string or function'); + return '**/*'; + } +} + +// The delete filter is a function that selects what files to delete. `keep` will be populated later +// as it sees files in the stream. +function getDeleteFilter(options, keep) { + if (typeof options.filter === 'function') { + const filter = options.filter; + return (name) => !keep.hasOwnProperty(name) && filter(name); + } else { + return (name) => !keep.hasOwnProperty(name); + } +} + +class PruneTransform extends Transform { + + constructor(dest, options) { + super({ objectMode: true }); + + // Accept prune(dest, [options]), prune(options) + verify(arguments.length <= 2, 'too many arguments'); + if (typeof dest === 'string') { + options = options || {}; + verify(typeof options === 'object', 'options must be an object'); + verify(options.dest === undefined, 'options.dest should not be specified with a dest argument'); + } else { + verify(options === undefined, 'dest must be a string'); + options = dest; + verify(typeof options === 'object', 'expected dest string or options object'); + dest = options.dest; + verify(typeof dest === 'string', 'options.dest or dest argument must be string'); + } + + this._dest = path.resolve(dest); + this._kept = {}; + this._mapper = getMapper(options); + this._pattern = getDeletePattern(options); + this._filter = getDeleteFilter(options, this._kept); + + verify(options.verbose === undefined || typeof options.verbose === 'boolean', 'options.verbose must be a boolean'); + this._verbose = !!options.verbose; + } + + _transform(file, encoding, callback) { + Promise.resolve() + .then(() => { + const name = path.relative(file.base, file.path); + return this._mapper(name); + }) + .then(mapped => { + switch (typeof mapped) { + case 'string': + this._kept[normalize(mapped)] = true; + break; + case 'object': + for (let i = 0; i < mapped.length; ++i) { + this._kept[normalize(mapped[i])] = true; + } + break; + default: + check(false, 'options.map function must return a string or string[], or a Promise that resolves to that.'); + } + }) + .then(() => callback(null, file), callback); + } + + _flush(callback) { + globby(this._pattern, { cwd: this._dest, nodir: true }) + .then(candidates => { + let deleting = candidates.filter(this._filter); + return Promise.all(deleting.map(f => { + let file = path.join(this._dest, f); + this._remove(file); + })); + }) + .then(deleted => callback(), callback); + } + + _remove(file) { + return new Promise((resolve, reject) => { + fs.unlink(file, (error) => { + try { + const fileRelative = path.relative('.', file); + if (error) { + if (this._verbose) { + gutil.log('Prune:', gutil.colors.red(`${fileRelative}: ${error.message || error}`)); + } + reject(new Error(`${fileRelative}: ${error.message || error}`)); + } else { + if (this._verbose) { + gutil.log('Prune:', gutil.colors.yellow(fileRelative)); + } + resolve(); + } + } catch (e) { + reject(e); + } + }); + }); + } +} + +module.exports = function prune(dest, options) { + return new PruneTransform(dest, options); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6543d82 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "gulp-prune", + "version": "0.1.0", + "description": "Delete extraneous files from target directory on stream flush", + "main": "index.js", + "scripts": { + "test": "mocha" + }, + "keywords": [ + "gulpplugin", + "prune", + "files" + ], + "author": { + "name": "Kurt Blackwell", + "url": "https://github.com/hh10k" + }, + "license": "ISC", + "devDependencies": { + "mocha": "^2.5.3", + "mock-fs": "^3.11.0" + }, + "dependencies": { + "globby": "^5.0.0", + "gulp-util": "^3.0.7" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..90b4f67 --- /dev/null +++ b/readme.md @@ -0,0 +1,108 @@ +# gulp-prune + +A [Gulp](http://gulpjs.com/) plugin to delete files that should not be in the destination directory. + +Files that have not been seen will be deleted after the stream is flushed. + +## Examples + +### Prune with 1:1 mapping + +All files in the target directory will be deleted unless they match the source name. + +```js +var gulp = require('gulp'); +var prune = require('gulp-prune'); +var newer = require('gulp-newer'); +var babel = require('gulp-babel'); + +gulp.task('build', () => { + return gulp.src('src/**/*.js') + .pipe(prune('build/')) + .pipe(newer('build/')) + .pipe(babel({ presets: ['es2015'] })) + .pipe(gulp.dest('build/')); +}); +``` + +### Prune with custom mapping + +If the source and destination files names are different then the mapping can be customised. + +```js +var gulp = require('gulp'); +var prune = require('gulp-prune'); +var newer = require('gulp-newer'); +var sourcemaps = require('gulp-sourcemaps'); +var typescript = require('gulp-typescript'); + +gulp.task('build', () => { + return gulp.src('src/**/*.ts') + .pipe(prune({ dest: 'build/', ext: [ '.js', '.js.map' ] })) + .pipe(newer({ dest: 'build/', ext: '.js' })) + .pipe(sourcemaps.init()) + .pipe(typescript()) + .pipe(sourcemaps.write('.')) + .pipe(gulp.dest('build/')); +}); +``` + +### Prune with restrictions + +If multiple build tasks all output to the same directory, add a filter so that prune only deletes what that task is expected to output. + +```js +var gulp = require('gulp'); +var prune = require('gulp-prune'); +var newer = require('gulp-newer'); +var imagemin = require('gulp-imagemin'); +var uglify = require('gulp-uglify'); + +gulp.task('build-images', () => { + return gulp.src('src/**/*@(.jpg|.png|.gif)') + .pipe(prune('build/', { filter: '**/*@(.jpg|.png|.gif)' })) + .pipe(newer('build/')) + .pipe(imagemin()) + .pipe(gulp.dest('build/')); +}); + +gulp.task('build-sources', () => { + return gulp.src('src/*.js') + .pipe(prune('build/', { filter: '*.js' })) + .pipe(newer('build/')) + .pipe(uglify()) + .pipe(gulp.dest('build/')); +}); +``` + +## API + +### Export + +- `prune(dest)` +- `prune(dest, options)` +- `prune(options)` + +### Options + +- `options.dest` (or `dest` argument) + + The directory to prune files from. + +- `options.map` + + A function that maps the source file name to what will be output to the `dest` directory. The function may return a string or array of string file names. + +- `options.ext` + + A convenience version of `options.map` to replace the file extension. May be a single string or an array of strings. + +- `options.filter` + + If a string, only files that match this [Minimatch](https://www.npmjs.com/package/minimatch) pattern may be pruned. + + If a function, will be called for each file to be pruned. Return true to delete it. + +- `options.verbose` + + Set to true to log all deleted files. diff --git a/test.js b/test.js new file mode 100644 index 0000000..3c776cc --- /dev/null +++ b/test.js @@ -0,0 +1,369 @@ +const mockFs = require('mock-fs'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const Transform = require('stream').Transform; +const globby = require('globby'); +const gutil = require('gulp-util'); +const prune = require('./index.js'); +const domain = require('domain'); + +const types = { + zero: 0, + positive: 1, + object: {}, + array: [], + true: true, + false: false, + null: null, + string: 'surprise', + function: () => {}, +}; + +class ExpectedError extends Error { +} + +class TestFile extends gutil.File { + constructor(base, file) { + super({ + cwd: process.cwd(), + base: path.resolve(base), + contents: new Buffer(fs.readFileSync(file)), + path: path.resolve(file), + stat: fs.statSync(file) + }) + } +} + +function find(pattern) { + return globby.sync(pattern, { nodir: true }); +} + +function testStream(done, stream, expectedDeleted) { + const d = domain.create(); + d.on('error', error => { + done(error); + }); + d.run(() => { + const original = find('**/*'); + assert(expectedDeleted.every(f => original.includes(f))); + const expectedResult = original.filter(f => !expectedDeleted.includes(f)); + + find('src/**/*').forEach(f => stream.write(new TestFile('src', f))); + + stream.on('data', file => { + // Empty callback required to pump files + }); + + stream.on('end', d.bind(() => { + const result = find('**/*'); + assert.deepEqual(result, expectedResult); + + done(); + })); + + stream.end(); + }); +} + +describe('prune()', function() { + + it('creates a transform stream', function() { + let stream = prune('somewhere'); + assert(stream instanceof Transform); + }); + + it('fails with no arguments', function() { + assert.throws(() => { + prune(); + }, gutil.PluginError); + }); + + it("fails when the first argument isn't a string or an object", function() { + assert.throws(() => { + prune(5); + }, gutil.PluginError); + }); + + it("fails when dest isn't an argument or in the options", function() { + assert.throws(() => { + prune({}); + }, gutil.PluginError); + }); + + it("fails when options isn't an object", function() { + assert.throws(() => { + prune('somewhere', 5); + }, gutil.PluginError); + }); + + it('fails when dest is specified two ways', function() { + assert.throws(() => { + prune('somewhere', { dest: 'elsewhere' }); + }, gutil.PluginError); + }); + + describe('returns Transform stream', function() { + + beforeEach(() => { + mockFs({ + 'outside': 'a file outside selected paths', + 'src/both-root-and-dir': '', + 'src/src-root-and-dir': '', + 'src/both-root': '', + 'src/src-root': '', + 'src/dir/both-root-and-dir': '', + 'src/dir/src-root-and-dir': '', + 'src/dir/both-dir': '', + 'src/dir/src-dir': '', + 'src/constructor': 'Name of property on Object. Unique to src.', + 'src/toLocaleString': 'Name of property on Object. Common to src and dest.', + 'dest/both-root-and-dir': '', + 'dest/dest-root-and-dir': '', + 'dest/both-root': '', + 'dest/dest-root': '', + 'dest/dir/both-root-and-dir': '', + 'dest/dir/dest-root-and-dir': '', + 'dest/dir/both-dir': '', + 'dest/dir/dest-dir': '', + 'dest/toString': 'Name of property on Object. Unique to dest.', + 'dest/toLocaleString': 'Name of property on Object. Common to src and dest.', + }); + }); + afterEach(() => { + mockFs.restore(); + }); + + it('passes data through', function(done) { + const d = domain.create(); + d.on('error', error => { + done(error); + }); + d.run(() => { + const stream = prune('dest'); + const files = find('src/**/*').map(f => new TestFile('src', f)); + + let count = 0; + stream.on('data', file => { + assert.equal(file, files[count]); + ++count; + }); + + files.forEach(f => stream.write(f)); + + stream.on('end', () => { + assert.equal(count, files.length); + done(); + }); + + stream.end(); + }); + }); + + it('deletes expected files', function(done) { + testStream(done, prune('dest'), [ + 'dest/dest-root-and-dir', + 'dest/dest-root', + 'dest/dir/dest-root-and-dir', + 'dest/dir/dest-dir', + 'dest/toString', + ]); + }); + }); + + describe('options.map', function() { + + beforeEach(() => { + mockFs({ + 'outside': 'a file outside selected paths', + 'src/1': '', + 'src/dir/2': '', + 'dest/outside': '', + 'dest/1': '', + 'dest/mapped1': 'only with simple transform', + 'dest/dest/1': 'only with directory transform', + 'dest/dest/dir/2': 'only with directory transform', + 'dest/dir/2': '', + 'dest/dir/mapped2': 'only with simple transform', + }); + }); + afterEach(() => { + mockFs.restore(); + }); + + it('must be a function', function() { + Object.keys(types) + .filter(t => t != 'function') + .forEach(t => { + assert.throws(() => { + prune('dest', { map: types[t] }); + }, gutil.PluginError, 'Should not accept ' + t); + }); + }); + + it('applies simple function transform', function(done) { + testStream(done, prune('dest', { map: f => path.join(path.dirname(f), 'mapped' + path.basename(f)) }), [ + 'dest/outside', + 'dest/1', + 'dest/dest/1', + 'dest/dir/2', + 'dest/dest/dir/2', + ]); + }); + + it('applies function transform with directory', function(done) { + testStream(done, prune({ dest: 'dest', map: f => path.join('dest', f) }), [ + 'dest/outside', + 'dest/1', + 'dest/mapped1', + 'dest/dir/2', + 'dest/dir/mapped2', + ]); + }); + + }); + + describe('options.ext', function() { + + beforeEach(() => { + mockFs({ + 'src/1.old': '', + 'src/2': '', + 'dest/1.old': '', + 'dest/1.old.new': '', + 'dest/1.new': '', + 'dest/1.new.map': '', + 'dest/2': '', + 'dest/2.new': '', + 'dest/2.new.map': '', + }); + }); + afterEach(() => { + mockFs.restore(); + }); + + it('must be a string or string[]', function() { + let extTypes = Object.assign({ 'number[]': [ 123 ] }, types); + Object.keys(extTypes) + .filter(t => t != 'string' && t != 'array') + .forEach(t => { + assert.throws(() => { + prune('dest', { ext: extTypes[t] }); + }, gutil.PluginError, 'Should not accept ' + t); + }); + }); + + it("can't be used with options.map", function() { + assert.throws(() => { + prune('dest', { map: f => f + '.js', ext: '.js' }); + }, gutil.PluginError); + }); + + it('adds or removes single extension', function(done) { + testStream(done, prune('dest', { ext: '.new' }), [ + 'dest/1.old', + 'dest/1.old.new', + 'dest/1.new.map', + 'dest/2', + 'dest/2.new.map', + ]); + }); + + it('adds or removes multiple extensions', function(done) { + testStream(done, prune({ dest: 'dest', ext: [ '.new', '.new.map' ] }), [ + 'dest/1.old', + 'dest/1.old.new', + 'dest/2', + ]); + }); + + }); + + describe('options.filter', function() { + + beforeEach(() => { + mockFs({ + 'dest/aaa': '', + 'dest/aab': '', + 'dest/aba': '', + 'dest/abb': '', + 'dest/baa': '', + 'dest/bab': '', + 'dest/bba': '', + 'dest/bbb': '', + }); + }); + afterEach(() => { + mockFs.restore(); + }); + + it('fails when not a string or function', function() { + Object.keys(types) + .filter(t => t != 'string' && t != 'function') + .forEach(t => { + assert.throws(() => { + prune('dest', { filter: types[t] }); + }, gutil.PluginError, 'Should not accept ' + t); + }); + }); + + it('only deletes files that match a string pattern', function(done) { + testStream(done, prune('dest', { filter: '?a?' }), [ + 'dest/aaa', + 'dest/aab', + 'dest/baa', + 'dest/bab', + ]); + }); + + it('only deletes files that match a function predicate', function(done) { + testStream(done, prune('dest', { filter: f => /.b.$/.test(f) }), [ + 'dest/aba', + 'dest/abb', + 'dest/bba', + 'dest/bbb', + ]); + }); + + it('propagates errors when filter throws', function(done) { + const d = domain.create(); + d.on('error', error => { + done(error); + }); + d.run(() => { + const stream = prune({ + dest: 'dest', + filter: () => { throw new ExpectedError(); } + }); + + stream.on('data', file => { + // Empty callback required to pump files + }); + + stream.on('end', d.bind(() => { + assert.fail('Did not see ExpectedError'); + })); + + stream.on('error', file => { + done(); + }); + + stream.end(); + }); + }); + }); + + describe('options.verbose', function() { + + it('fails when not a boolean', function() { + Object.keys(types) + .filter(t => t != 'true' && t != 'false') + .forEach(t => { + assert.throws(() => { + prune('dest', { verbose: types[t] }); + }, gutil.PluginError, 'Should not accept ' + t); + }); + }); + + }); +}); \ No newline at end of file