diff --git a/.travis.yml b/.travis.yml index f98fed0..29ef4bf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,7 @@ +os: + - linux + - osx + - windows language: node_js node_js: - '12' diff --git a/benchmark.js b/benchmark.js new file mode 100644 index 0000000..9cfd6db --- /dev/null +++ b/benchmark.js @@ -0,0 +1,84 @@ +'use strict'; +const path = require('path'); +const Benchmark = require('benchmark'); +const makeDir = require('make-dir'); +const tempy = require('tempy'); +const del = require('.'); + +const suite = new Benchmark.Suite('concurrency'); + +const tempDir = tempy.directory(); + +const fixtures = Array.from({length: 2000}, (_, index) => { + return path.resolve(tempDir, (index + 1).toString()); +}); + +function createFixtures() { + for (const fixture of fixtures) { + makeDir.sync(path.resolve(tempDir, fixture)); + } +} + +const concurrencies = [ + 1, + 3, + 5, + 10, + 15, + 20, + 50, + 100, + 200, + 300, + 400, + 500, + 1000, + Infinity +]; + +for (const concurrency of concurrencies) { + const name = `concurrency: ${concurrency.toString()}`; + + suite.add({ + name, + defer: true, + setup() {}, // This line breaks async await + async fn(deferred) { + // Can't use `setup()` because it isn't called after every + // defer and it breaks using `async` keyword here. + // https://github.com/bestiejs/benchmark.js/issues/136 + createFixtures(); + + const removedFiles = await del(['**/*'], { + cwd: tempDir, + concurrency + }); + + if (removedFiles.length !== fixtures.length) { + const error = new Error( + `"${name}": files removed: ${removedFiles.length}, expected: ${fixtures.length}`, + ); + + console.error(error); + + del.sync(tempDir, {cwd: tempDir, force: true}); + + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + + deferred.resolve(); + } + }); +} + +suite + .on('cycle', event => { + console.log(String(event.target)); + }) + .on('complete', function () { + console.log(`Fastest is ${this.filter('fastest').map('name')}`); + + del.sync(tempDir, {cwd: tempDir, force: true}); + }) + .run({async: true}); diff --git a/index.d.ts b/index.d.ts index 2ef233d..b4d9767 100644 --- a/index.d.ts +++ b/index.d.ts @@ -40,10 +40,12 @@ declare const del: { /** Delete files and directories using glob patterns. + Note that glob patterns can only contain forward-slashes, not backward-slashes. Windows file paths can use backward-slashes as long as the path does not contain any glob-like characters, otherwise use `path.posix.join()` instead of `path.join()`. + @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). - [Pattern examples with expected matches](https://github.com/sindresorhus/multimatch/blob/master/test/test.js) - [Quick globbing pattern overview](https://github.com/sindresorhus/multimatch#globbing-patterns) - @param options - You can specify any of the [`globby` options](https://github.com/sindresorhus/globby#options) in addition to the `del` options. In constrast to the `globby` defaults, `expandDirectories`, `onlyFiles`, and `followSymbolicLinks` are `false` by default. + @param options - You can specify any of the [`globby` options](https://github.com/sindresorhus/globby#options) in addition to the `del` options. In contrast to the `globby` defaults, `expandDirectories`, `onlyFiles`, and `followSymbolicLinks` are `false` by default. @returns The deleted paths. @example @@ -65,10 +67,12 @@ declare const del: { /** Synchronously delete files and directories using glob patterns. + Note that glob patterns can only contain forward-slashes, not backward-slashes. Windows file paths can use backward-slashes as long as the path does not contain any glob-like characters, otherwise use `path.posix.join()` instead of `path.join()`. + @param patterns - See the supported [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns). - [Pattern examples with expected matches](https://github.com/sindresorhus/multimatch/blob/master/test/test.js) - [Quick globbing pattern overview](https://github.com/sindresorhus/multimatch#globbing-patterns) - @param options - You can specify any of the [`globby` options](https://github.com/sindresorhus/globby#options) in addition to the `del` options. In constrast to the `globby` defaults, `expandDirectories`, `onlyFiles`, and `followSymbolicLinks` are `false` by default. + @param options - You can specify any of the [`globby` options](https://github.com/sindresorhus/globby#options) in addition to the `del` options. In contrast to the `globby` defaults, `expandDirectories`, `onlyFiles`, and `followSymbolicLinks` are `false` by default. @returns The deleted paths. */ sync( diff --git a/index.js b/index.js index 1d15158..e1956b8 100644 --- a/index.js +++ b/index.js @@ -2,69 +2,120 @@ const {promisify} = require('util'); const path = require('path'); const globby = require('globby'); +const isGlob = require('is-glob'); +const slash = require('slash'); +const gracefulFs = require('graceful-fs'); const isPathCwd = require('is-path-cwd'); -const isPathInCwd = require('is-path-in-cwd'); +const isPathInside = require('is-path-inside'); const rimraf = require('rimraf'); const pMap = require('p-map'); const rimrafP = promisify(rimraf); -function safeCheck(file) { +const rimrafOptions = { + glob: false, + unlink: gracefulFs.unlink, + unlinkSync: gracefulFs.unlinkSync, + chmod: gracefulFs.chmod, + chmodSync: gracefulFs.chmodSync, + stat: gracefulFs.stat, + statSync: gracefulFs.statSync, + lstat: gracefulFs.lstat, + lstatSync: gracefulFs.lstatSync, + rmdir: gracefulFs.rmdir, + rmdirSync: gracefulFs.rmdirSync, + readdir: gracefulFs.readdir, + readdirSync: gracefulFs.readdirSync +}; + +function safeCheck(file, cwd) { if (isPathCwd(file)) { throw new Error('Cannot delete the current working directory. Can be overridden with the `force` option.'); } - if (!isPathInCwd(file)) { + if (!isPathInside(file, cwd)) { throw new Error('Cannot delete files/directories outside the current working directory. Can be overridden with the `force` option.'); } } -module.exports = async (patterns, {force, dryRun, ...options} = {}) => { +function normalizePatterns(patterns) { + patterns = Array.isArray(patterns) ? patterns : [patterns]; + + patterns = patterns.map(pattern => { + if (process.platform === 'win32' && isGlob(pattern) === false) { + return slash(pattern); + } + + return pattern; + }); + + return patterns; +} + +module.exports = async (patterns, {force, dryRun, cwd = process.cwd(), ...options} = {}) => { options = { expandDirectories: false, onlyFiles: false, followSymbolicLinks: false, + cwd, ...options }; - const files = await globby(patterns, options); + patterns = normalizePatterns(patterns); + + const files = (await globby(patterns, options)) + .sort((a, b) => b.localeCompare(a)); const mapper = async file => { + file = path.resolve(cwd, file); + if (!force) { - safeCheck(file); + safeCheck(file, cwd); } - file = path.resolve(options.cwd || '', file); - if (!dryRun) { - await rimrafP(file, {glob: false}); + await rimrafP(file, rimrafOptions); } return file; }; - return pMap(files, mapper, options); + const removedFiles = await pMap(files, mapper, options); + + removedFiles.sort((a, b) => a.localeCompare(b)); + + return removedFiles; }; -module.exports.sync = (patterns, {force, dryRun, ...options} = {}) => { +module.exports.sync = (patterns, {force, dryRun, cwd = process.cwd(), ...options} = {}) => { options = { expandDirectories: false, onlyFiles: false, followSymbolicLinks: false, + cwd, ...options }; - return globby.sync(patterns, options).map(file => { + patterns = normalizePatterns(patterns); + + const files = globby.sync(patterns, options) + .sort((a, b) => b.localeCompare(a)); + + const removedFiles = files.map(file => { + file = path.resolve(cwd, file); + if (!force) { - safeCheck(file); + safeCheck(file, cwd); } - file = path.resolve(options.cwd || '', file); - if (!dryRun) { - rimraf.sync(file, {glob: false}); + rimraf.sync(file, rimrafOptions); } return file; }); + + removedFiles.sort((a, b) => a.localeCompare(b)); + + return removedFiles; }; diff --git a/package.json b/package.json index c4bbb12..ad41ccf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "del", - "version": "5.0.0", + "version": "5.1.0", "description": "Delete files and directories", "license": "MIT", "repository": "sindresorhus/del", @@ -13,7 +13,8 @@ "node": ">=8" }, "scripts": { - "test": "xo && ava && tsd" + "test": "xo && ava && tsd", + "bench": "node benchmark.js" }, "files": [ "index.js", @@ -44,17 +45,21 @@ "filesystem" ], "dependencies": { - "globby": "^10.0.0", - "is-path-cwd": "^2.0.0", - "is-path-in-cwd": "^2.0.0", - "p-map": "^2.0.0", - "rimraf": "^2.6.3" + "globby": "^10.0.1", + "graceful-fs": "^4.2.2", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.1", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "slash": "^3.0.0" }, "devDependencies": { - "ava": "^2.1.0", + "ava": "^2.3.0", + "benchmark": "^2.1.4", "make-dir": "^3.0.0", "tempy": "^0.3.0", - "tsd": "^0.7.3", + "tsd": "^0.7.4", "xo": "^0.24.0" } } diff --git a/readme.md b/readme.md index 59f83a1..36c0f06 100644 --- a/readme.md +++ b/readme.md @@ -46,6 +46,8 @@ Suggestions on how to improve this welcome! ## API +Note that glob patterns can only contain forward-slashes, not backward-slashes. Windows file paths can use backward-slashes as long as the path does not contain any glob-like characters, otherwise use `path.posix.join()` instead of `path.join()`. + ### del(patterns, options?) Returns `Promise` with the deleted paths. @@ -67,7 +69,7 @@ See the supported [glob patterns](https://github.com/sindresorhus/globby#globbin Type: `object` -You can specify any of the [`globby` options](https://github.com/sindresorhus/globby#options) in addition to the below options. In constrast to the `globby` defaults, `expandDirectories`, `onlyFiles`, and `followSymbolicLinks` are `false` by default. +You can specify any of the [`globby` options](https://github.com/sindresorhus/globby#options) in addition to the below options. In contrast to the `globby` defaults, `expandDirectories`, `onlyFiles`, and `followSymbolicLinks` are `false` by default. ##### force diff --git a/test.js b/test.js index 036cd32..d5a4fcf 100644 --- a/test.js +++ b/test.js @@ -1,10 +1,12 @@ import path from 'path'; import fs from 'fs'; -import test from 'ava'; +import {serial as test} from 'ava'; import tempy from 'tempy'; import makeDir from 'make-dir'; import del from '.'; +const processCwd = process.cwd(); + function exists(t, files) { for (const file of files) { t.true(fs.existsSync(path.join(t.context.tmp, file))); @@ -67,7 +69,7 @@ test('take options into account - sync', t => { notExists(t, ['2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); }); -test.serial('return deleted files - async', async t => { +test('return deleted files - async', async t => { t.deepEqual( await del('1.tmp', {cwd: t.context.tmp}), [path.join(t.context.tmp, '1.tmp')] @@ -106,3 +108,244 @@ test('don\'t delete files, but return them - sync', t => { path.join(t.context.tmp, '4.tmp') ]); }); + +// Currently this is only testable locally on macOS. +// https://github.com/sindresorhus/del/issues/68 +test('does not throw EINVAL - async', async t => { + await del('**/*', { + cwd: t.context.tmp, + dot: true + }); + + const nestedFile = path.resolve(t.context.tmp, 'a/b/c/nested.js'); + const totalAttempts = 200; + + let count = 0; + while (count !== totalAttempts) { + makeDir.sync(nestedFile); + + // eslint-disable-next-line no-await-in-loop + const removed = await del('**/*', { + cwd: t.context.tmp, + dot: true + }); + + const expected = [ + path.resolve(t.context.tmp, 'a'), + path.resolve(t.context.tmp, 'a/b'), + path.resolve(t.context.tmp, 'a/b/c'), + path.resolve(t.context.tmp, 'a/b/c/nested.js') + ]; + + t.deepEqual(removed, expected); + + count += 1; + } + + notExists(t, [...fixtures, 'a']); + t.is(count, totalAttempts); +}); + +test('does not throw EINVAL - sync', t => { + del.sync('**/*', { + cwd: t.context.tmp, + dot: true + }); + + const nestedFile = path.resolve(t.context.tmp, 'a/b/c/nested.js'); + const totalAttempts = 200; + + let count = 0; + while (count !== totalAttempts) { + makeDir.sync(nestedFile); + + const removed = del.sync('**/*', { + cwd: t.context.tmp, + dot: true + }); + + const expected = [ + path.resolve(t.context.tmp, 'a'), + path.resolve(t.context.tmp, 'a/b'), + path.resolve(t.context.tmp, 'a/b/c'), + path.resolve(t.context.tmp, 'a/b/c/nested.js') + ]; + + t.deepEqual(removed, expected); + + count += 1; + } + + notExists(t, [...fixtures, 'a']); + t.is(count, totalAttempts); +}); + +test('delete relative files outside of process.cwd using cwd - async', async t => { + await del(['1.tmp'], {cwd: t.context.tmp}); + + exists(t, ['2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + notExists(t, ['1.tmp']); +}); + +test('delete relative files outside of process.cwd using cwd - sync', t => { + del.sync(['1.tmp'], {cwd: t.context.tmp}); + + exists(t, ['2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + notExists(t, ['1.tmp']); +}); + +test('delete absolute files outside of process.cwd using cwd - async', async t => { + const absolutePath = path.resolve(t.context.tmp, '1.tmp'); + await del([absolutePath], {cwd: t.context.tmp}); + + exists(t, ['2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + notExists(t, ['1.tmp']); +}); + +test('delete absolute files outside of process.cwd using cwd - sync', t => { + const absolutePath = path.resolve(t.context.tmp, '1.tmp'); + del.sync([absolutePath], {cwd: t.context.tmp}); + + exists(t, ['2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + notExists(t, ['1.tmp']); +}); + +test('cannot delete actual working directory without force: true - async', async t => { + process.chdir(t.context.tmp); + + await t.throwsAsync(del([t.context.tmp]), { + instanceOf: Error, + message: 'Cannot delete the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['', '1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + process.chdir(processCwd); +}); + +test('cannot delete actual working directory without force: true - sync', t => { + process.chdir(t.context.tmp); + + t.throws(() => { + del.sync([t.context.tmp]); + }, { + instanceOf: Error, + message: 'Cannot delete the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['', '1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + process.chdir(processCwd); +}); + +test('cannot delete actual working directory with cwd option without force: true - async', async t => { + process.chdir(t.context.tmp); + + await t.throwsAsync(del([t.context.tmp], {cwd: __dirname}), { + instanceOf: Error, + message: 'Cannot delete the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['', '1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + process.chdir(processCwd); +}); + +test('cannot delete actual working directory with cwd option without force: true - sync', t => { + process.chdir(t.context.tmp); + + t.throws(() => { + del.sync([t.context.tmp], {cwd: __dirname}); + }, { + instanceOf: Error, + message: 'Cannot delete the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['', '1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + process.chdir(processCwd); +}); + +test('cannot delete files outside cwd without force: true - async', async t => { + const absolutePath = path.resolve(t.context.tmp, '1.tmp'); + + await t.throwsAsync(del([absolutePath]), { + instanceOf: Error, + message: 'Cannot delete files/directories outside the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); +}); + +test('cannot delete files outside cwd without force: true - sync', t => { + const absolutePath = path.resolve(t.context.tmp, '1.tmp'); + + t.throws(() => { + del.sync([absolutePath]); + }, { + instanceOf: Error, + message: 'Cannot delete files/directories outside the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['', '1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); +}); + +test('cannot delete files inside process.cwd when outside cwd without force: true - async', async t => { + process.chdir(t.context.tmp); + const removeFile = path.resolve(t.context.tmp, '2.tmp'); + const cwd = path.resolve(t.context.tmp, '1.tmp'); + + await t.throwsAsync(del([removeFile], {cwd}), { + instanceOf: Error, + message: 'Cannot delete files/directories outside the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + process.chdir(processCwd); +}); + +test('cannot delete files inside process.cwd when outside cwd without force: true - sync', t => { + process.chdir(t.context.tmp); + const removeFile = path.resolve(t.context.tmp, '2.tmp'); + const cwd = path.resolve(t.context.tmp, '1.tmp'); + + t.throws(() => { + del.sync([removeFile], {cwd}); + }, { + instanceOf: Error, + message: 'Cannot delete files/directories outside the current working directory. Can be overridden with the `force` option.' + }); + + exists(t, ['1.tmp', '2.tmp', '3.tmp', '4.tmp', '.dot.tmp']); + process.chdir(processCwd); +}); + +test('windows can pass absolute paths with "\\" - async', async t => { + const filePath = path.resolve(t.context.tmp, '1.tmp'); + + const removeFiles = await del([filePath], {cwd: t.context.tmp, dryRun: true}); + + t.deepEqual(removeFiles, [filePath]); +}); + +test('windows can pass absolute paths with "\\" - sync', t => { + const filePath = path.resolve(t.context.tmp, '1.tmp'); + + const removeFiles = del.sync([filePath], {cwd: t.context.tmp, dryRun: true}); + + t.deepEqual(removeFiles, [filePath]); +}); + +test('windows can pass relative paths with "\\" - async', async t => { + const nestedFile = path.resolve(t.context.tmp, 'a/b/c/nested.js'); + makeDir.sync(nestedFile); + + const removeFiles = await del([nestedFile], {cwd: t.context.tmp, dryRun: true}); + + t.deepEqual(removeFiles, [nestedFile]); +}); + +test('windows can pass relative paths with "\\" - sync', t => { + const nestedFile = path.resolve(t.context.tmp, 'a/b/c/nested.js'); + makeDir.sync(nestedFile); + + const removeFiles = del.sync([nestedFile], {cwd: t.context.tmp, dryRun: true}); + + t.deepEqual(removeFiles, [nestedFile]); +});