diff --git a/.editorconfig b/.editorconfig index 511c617..0b80e5d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,5 +10,4 @@ insert_final_newline = true trim_trailing_whitespace = true [*.md] -trim_trailing_whitespace = true -insert_final_newline = true +trim_trailing_whitespace = false diff --git a/.eslintrc.json b/.eslintrc.json index 24b8984..514284f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,29 +4,18 @@ ], "env": { - "browser": false, - "es6": true, - "node": true, - "mocha": true + "es2021": true, + "node": true }, - "parserOptions":{ - "ecmaVersion": 9, + "parserOptions": { "sourceType": "module", - "ecmaFeatures": { - "modules": true, - "experimentalObjectRestSpread": true - } - }, - - "globals": { - "document": false, - "navigator": false, - "window": false + "ecmaVersion": 12 }, "rules": { "accessor-pairs": 2, + "arrow-parens": [2, "as-needed"], "arrow-spacing": [2, { "before": true, "after": true }], "block-spacing": [2, "always"], "brace-style": [2, "1tbs", { "allowSingleLine": true }], @@ -39,7 +28,7 @@ "eol-last": 2, "eqeqeq": [2, "allow-null"], "generator-star-spacing": [2, { "before": true, "after": true }], - "handle-callback-err": [2, "^(err|error)$" ], + "handle-callback-err": [2, "^(err|error)$"], "indent": [2, 2, { "SwitchCase": 1 }], "key-spacing": [2, { "beforeColon": false, "afterColon": true }], "keyword-spacing": [2, { "before": true, "after": true }], @@ -68,6 +57,7 @@ "no-floating-decimal": 2, "no-func-assign": 2, "no-implied-eval": 2, + "no-implicit-coercion": 2, "no-inner-declarations": [2, "functions"], "no-invalid-regexp": 2, "no-irregular-whitespace": 2, @@ -75,11 +65,12 @@ "no-label-var": 2, "no-labels": 2, "no-lone-blocks": 2, + "no-lonely-if": 2, "no-mixed-spaces-and-tabs": 2, - "no-multi-spaces": 2, + "no-multi-spaces": 0, "no-multi-str": 2, "no-multiple-empty-lines": [2, { "max": 1 }], - "no-native-reassign": 0, + "no-native-reassign": 2, "no-negated-in-lhs": 2, "no-new": 2, "no-new-func": 2, @@ -89,7 +80,7 @@ "no-obj-calls": 2, "no-octal": 2, "no-octal-escape": 2, - "no-proto": 0, + "no-proto": 2, "no-redeclare": 2, "no-regex-spaces": 2, "no-return-assign": 2, @@ -100,28 +91,32 @@ "no-sparse-arrays": 2, "no-this-before-super": 2, "no-throw-literal": 2, - "no-trailing-spaces": 0, + "no-trailing-spaces": 2, "no-undef": 2, "no-undef-init": 2, "no-unexpected-multiline": 2, "no-unneeded-ternary": [2, { "defaultAssignment": false }], "no-unreachable": 2, + "no-unused-expressions": 2, "no-unused-vars": [2, { "vars": "all", "args": "none" }], - "no-useless-call": 0, + "no-useless-call": 2, "no-with": 2, - "one-var": [0, { "initialized": "never" }], + "object-curly-spacing": ["error", "always", { "objectsInObjects": true }], + "one-var": [2, { "initialized": "never" }], "operator-linebreak": [0, "after", { "overrides": { "?": "before", ":": "before" } }], "padded-blocks": [0, "never"], + "prefer-const": [2, { "destructuring": "all", "ignoreReadBeforeAssign": false }], "quotes": [2, "single", "avoid-escape"], "radix": 2, "semi": [2, "always"], "semi-spacing": [2, { "before": false, "after": true }], "space-before-blocks": [2, "always"], - "space-before-function-paren": [2, "never"], + "space-before-function-paren": [2, { "anonymous": "never", "named": "never", "asyncArrow": "always" }], "space-in-parens": [2, "never"], "space-infix-ops": 2, "space-unary-ops": [2, { "words": true, "nonwords": false }], "spaced-comment": [0, "always", { "markers": ["global", "globals", "eslint", "eslint-disable", "*package", "!", ","] }], + "strict": 2, "use-isnan": 2, "valid-typeof": 2, "wrap-iife": [2, "any"], diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c91da60 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Tests +on: [push, pull_request] + +jobs: + test: + name: Node.js ${{ matrix.node-version }} @ ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node-version: [12, 14, 16] + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ce7ae03..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -sudo: false -os: - - linux - - osx - - windows -language: node_js -node_js: - - node - - '12' - - '10' diff --git a/.verb.md b/.verb.md index 0357265..002d1fb 100644 --- a/.verb.md +++ b/.verb.md @@ -2,11 +2,12 @@ ```js const deleteEmpty = require('{%= name %}'); +import deleteEmpty from 'delete-empty'; ``` ## API -Given the following directory structure, the **highlighted directories** would be deleted. +Given the following directory structure, the **marked directories** would be deleted. ```diff foo/ @@ -23,36 +24,26 @@ foo/ ### async-await (promise) -If no callback is passed, a promise is returned. Returns the array of deleted directories. +Returns a promise that returns an object with the array of deleted directories. ```js (async () => { - let deleted = await deleteEmpty('foo'); + const { deleted } = await deleteEmpty('foo'); console.log(deleted); //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] })(); // or deleteEmpty('foo/') - .then(deleted => console.log(deleted)) //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] + .then(({ deleted }) => console.log(deleted)) //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] .catch(console.error); ``` -### async callback - -Returns the array of deleted directories in the callback. - -```js -deleteEmpty('foo/', (err, deleted) => { - console.log(deleted); //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] -}); -``` - ### sync -Returns the array of deleted directories. +Returns an object with the array of deleted directories. ```js -console.log(deleteEmpty.sync('foo/')); //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] +console.log(deleteEmpty.sync('foo/')); //=> { deleted: ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] } ``` diff --git a/LICENSE b/LICENSE index 7cccaf9..27da7ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015-present, Jon Schlinkert. +Copyright (c) Jon Schlinkert (jonschlinkert.dev) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d969996..a6ba909 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# delete-empty [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=W8YFZ425KND68) [![NPM version](https://img.shields.io/npm/v/delete-empty.svg?style=flat)](https://www.npmjs.com/package/delete-empty) [![NPM monthly downloads](https://img.shields.io/npm/dm/delete-empty.svg?style=flat)](https://npmjs.org/package/delete-empty) [![NPM total downloads](https://img.shields.io/npm/dt/delete-empty.svg?style=flat)](https://npmjs.org/package/delete-empty) [![Linux Build Status](https://img.shields.io/travis/jonschlinkert/delete-empty.svg?style=flat&label=Travis)](https://travis-ci.org/jonschlinkert/delete-empty) +# delete-empty [![NPM version](https://img.shields.io/npm/v/delete-empty.svg?style=flat)](https://www.npmjs.com/package/delete-empty) [![NPM monthly downloads](https://img.shields.io/npm/dm/delete-empty.svg?style=flat)](https://npmjs.org/package/delete-empty) [![NPM total downloads](https://img.shields.io/npm/dt/delete-empty.svg?style=flat)](https://npmjs.org/package/delete-empty) [![Tests](https://github.com/jonschlinkert/delete-empty/actions/workflows/test.yml/badge.svg)](https://github.com/jonschlinkert/delete-empty/actions/workflows/test.yml) > Recursively delete all empty folders in a directory and child directories. @@ -8,7 +8,6 @@ Please consider following this project's author, [Jon Schlinkert](https://github - [Usage](#usage) - [API](#api) * [async-await (promise)](#async-await-promise) - * [async callback](#async-callback) * [sync](#sync) - [CLI](#cli) - [About](#about) @@ -17,7 +16,7 @@ _(TOC generated by [verb](https://github.com/verbose/verb) using [markdown-toc]( ## Install -Install with [npm](https://www.npmjs.com/): +Install with [npm](https://www.npmjs.com/) (requires [Node.js](https://nodejs.org/en/) >=10): ```sh $ npm install --save delete-empty @@ -27,57 +26,48 @@ $ npm install --save delete-empty ```js const deleteEmpty = require('delete-empty'); +import deleteEmpty from 'delete-empty'; ``` ## API -Given the following directory structure, the **highlighted directories** would be deleted. +Given the following directory structure, the **marked directories** would be deleted. ```diff foo/ └─┬ a/ -- ├── aa/ +- ├── aa/ ├── bb/ │ └─┬ bbb/ │ │ ├── one.txt │ │ └── two.txt -- ├── cc/ -- ├ b/ -- └ c/ +- ├── cc/ +- ├ b/ +- └ c/ ``` ### async-await (promise) -If no callback is passed, a promise is returned. Returns the array of deleted directories. +Returns a promise that returns an object with the array of deleted directories. ```js (async () => { - let deleted = await deleteEmpty('foo'); + const { deleted } = await deleteEmpty('foo'); console.log(deleted); //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] })(); // or deleteEmpty('foo/') - .then(deleted => console.log(deleted)) //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] + .then(({ deleted }) => console.log(deleted)) //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] .catch(console.error); ``` -### async callback - -Returns the array of deleted directories in the callback. - -```js -deleteEmpty('foo/', (err, deleted) => { - console.log(deleted); //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] -}); -``` - ### sync -Returns the array of deleted directories. +Returns an object with the array of deleted directories. ```js -console.log(deleteEmpty.sync('foo/')); //=> ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] +console.log(deleteEmpty.sync('foo/')); //=> { deleted: ['foo/aa/', 'foo/a/cc/', 'foo/b/', 'foo/c/'] } ``` ## CLI @@ -144,16 +134,17 @@ You might also be interested in these projects: * [copy](https://www.npmjs.com/package/copy): Copy files or directories using globs. | [homepage](https://github.com/jonschlinkert/copy "Copy files or directories using globs.") * [delete](https://www.npmjs.com/package/delete): Delete files and folders and any intermediate directories if they exist (sync and async). | [homepage](https://github.com/jonschlinkert/delete "Delete files and folders and any intermediate directories if they exist (sync and async).") -* [fs-utils](https://www.npmjs.com/package/fs-utils): fs extras and utilities to extend the node.js file system module. Used in Assemble and… [more](https://github.com/assemble/fs-utils) | [homepage](https://github.com/assemble/fs-utils "fs extras and utilities to extend the node.js file system module. Used in Assemble and many other projects.") * [matched](https://www.npmjs.com/package/matched): Adds array support to node-glob, sync and async. Also supports tilde expansion (user home) and… [more](https://github.com/jonschlinkert/matched) | [homepage](https://github.com/jonschlinkert/matched "Adds array support to node-glob, sync and async. Also supports tilde expansion (user home) and resolving to global npm modules.") +* [micromatch](https://www.npmjs.com/package/micromatch): Glob matching for javascript/node.js. A replacement and faster alternative to minimatch and multimatch. | [homepage](https://github.com/micromatch/micromatch "Glob matching for javascript/node.js. A replacement and faster alternative to minimatch and multimatch.") ### Contributors | **Commits** | **Contributor** | | --- | --- | -| 31 | [jonschlinkert](https://github.com/jonschlinkert) | +| 35 | [jonschlinkert](https://github.com/jonschlinkert) | | 2 | [treble-snake](https://github.com/treble-snake) | | 1 | [doowb](https://github.com/doowb) | +| 1 | [Ronald-Baars](https://github.com/Ronald-Baars) | | 1 | [svenschoenung](https://github.com/svenschoenung) | | 1 | [vpalmisano](https://github.com/vpalmisano) | @@ -167,9 +158,9 @@ You might also be interested in these projects: ### License -Copyright © 2019, [Jon Schlinkert](https://github.com/jonschlinkert). +Copyright © 2021, [Jon Schlinkert](https://github.com/jonschlinkert). Released under the [MIT License](LICENSE). *** -_This file was generated by [verb-generate-readme](https://github.com/verbose/verb-generate-readme), v0.8.0, on July 02, 2019._ \ No newline at end of file +_This file was generated by [verb-generate-readme](https://github.com/verbose/verb-generate-readme), v0.8.0, on July 02, 2019._ diff --git a/bin/cli.js b/bin/cli.js index 1ad4546..de38cfc 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,61 +1,70 @@ #!/usr/bin/env node -const os = require('os'); -const path = require('path'); -const { cyan, bold, dim, green, symbols } = require('ansi-colors'); -const deleteEmpty = require('..'); -const argv = require('minimist')(process.argv.slice(2), { +import fs from 'fs'; +import path from 'path'; +import minimist from 'minimist'; +import colors from 'ansi-colors'; +import deleteEmpty from '../index.js'; + +const pkg = JSON.parse(fs.readFileSync(path.join('../package.json'))); +const { cyan, bold } = colors; + +const argv = minimist(process.argv.slice(2), { boolean: true, number: true, - alias: { d: 'dryRun' }, - rename: { _: 'files' } + alias: { + c: 'cwd', + d: ['dry-run', 'dryRun'], + h: 'help', + r: ['delete-root', 'deleteRoot'], + v: 'version' + } }); -const moduleDir = dim(`<${path.dirname(__dirname)}>`); -const name = pkg => bold(`delete-empty v${pkg.version}`); -const help = pkg => ` -Path: <${path.dirname(__dirname)}> +const version = () => `delete-empty ${cyan(`v${pkg.version}`)}`; -Usage: ${cyan('$ delete-empty [options]')} +const help = () => ` + Path: <${path.dirname(__dirname)}> -Directory: (optional) Initial directory to begin the search for empty - directories. Otherwise, cwd is used. + Usage: ${cyan('$ delete-empty [options]')} -[Options]: - -c, --cwd Set the current working directory for folders to search. - -d, --dryRun Do a dry run without deleting any files. - -h, --help Display this help menu - -V, --version Display the current version of rename - -v, --verbose Display all verbose logging messages (currently not used) + Example: ${cyan('$ delete-empty . [options]')} + + Directory: (optional) Initial directory to begin the search for empty + directories. Default is process.cwd(). + + [Options]: + -d, --dry-run Do a dry run without deleting any files + -r, --delete-root Also delete the starting dir if it's empty + -h, --help Display this help menu + -V, --version Display the current version `; if (argv.help) { - console.log(help(require('../package'))); + console.log(help()); process.exit(); } -const ok = green(symbols.check); -const cwd = path.resolve(argv._[0] || argv.cwd || process.cwd()); -const relative = filepath => { - if (filepath.startsWith(cwd)) { - return path.relative(cwd, filepath); - } - if (filepath.startsWith(os.homedir())) { - return path.join('~', filepath.slice(os.homedir().length)); - } - return cwd; -}; - -if (argv.dryRun) { - argv.verbose = true; +if (argv.version) { + console.log(version()); + process.exit(); } -let count = 0; -argv.onDirectory = () => (count++); +console.log(bold(version())); +const dir = argv._[0] || argv.cwd; +const cwd = dir ? path.resolve(dir) : process.cwd(); deleteEmpty(cwd, argv) - .then(files => { - console.log(ok, 'Deleted', files.length, 'of', count, 'directories'); + .then(({ deleted }) => { + console.log('deleted', deleted.length, 'empty directories'); + + const usage = process.memoryUsage(); + console.log('memory usage:'); + + for (const [key, value] of Object.entries(usage)) { + console.log(` ${key} ${Math.round(value / 1024 / 1024 * 100) / 100} MB`); + } + process.exit(); }) .catch(err => { diff --git a/examples/async.js b/examples/async.js index da62420..e146d2c 100644 --- a/examples/async.js +++ b/examples/async.js @@ -1,14 +1,21 @@ -'use strict'; -var copy = require('../test/support/copy'); -var deleteEmpty = require('..'); +import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; +import copy from '../test/support/copy.js'; +import deleteEmpty from '../index.js'; -copy('test/fixtures', 'test/temp', function(err) { - if (err) return console.log(err); - console.log('copied fixtures'); +const dirname = path.dirname(new URL(import.meta.url).pathname); +const destroy = util.promisify(rimraf); +const fixtures = path.join(dirname, '../test/fixtures'); +const temp = path.join(dirname, 'temp'); - deleteEmpty('test/temp', function(err, deleted) { - if (err) return console.log(err); +copy(fixtures, temp) + .then(() => deleteEmpty(temp)) + .then(({ deleted }) => { console.log('deleted', deleted); + return destroy(temp, { glob: false }); + }) + .catch(err => { + console.log(err); }); -}); diff --git a/examples/sync.js b/examples/sync.js index 6c300c1..aa0e35c 100644 --- a/examples/sync.js +++ b/examples/sync.js @@ -1,12 +1,21 @@ -'use strict'; -var copy = require('../test/support/copy'); -var deleteEmpty = require('..'); +import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; +import copy from '../test/support/copy.js'; +import deleteEmpty from '../index.js'; -copy('test/fixtures', 'test/temp', function(err) { - if (err) return console.log(err); - console.log('copied fixtures'); +const dirname = path.dirname(new URL(import.meta.url).pathname); +const destroy = util.promisify(rimraf); +const fixtures = path.join(dirname, '../test/fixtures'); +const temp = path.join(dirname, 'temp'); - var deleted = deleteEmpty.sync('test/temp'); - console.log('deleted', deleted); -}); +copy(fixtures, temp) + .then(() => { + const { deleted } = deleteEmpty.sync(temp); + console.log('deleted', deleted); + return destroy(temp, { glob: false }); + }) + .catch(err => { + console.log(err); + }); diff --git a/index.js b/index.js index edcd6f7..3bd9eb8 100644 --- a/index.js +++ b/index.js @@ -1,182 +1,182 @@ /*! * delete-empty - * Copyright (c) 2015-present, Jon Schlinkert + * Copyright (c) Jon Schlinkert (jonschlinkert.dev) * Released under the MIT License. */ -'use strict'; +import fs from 'fs'; +import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; +import startsWith from 'path-starts-with'; +import junk from 'junk'; +import systemPathRegex from './lib/system-path-regex.js'; -const fs = require('fs'); -const util = require('util'); -const path = require('path'); -const rimraf = require('rimraf'); -const startsWith = require('path-starts-with'); -const colors = require('ansi-colors'); -const readdir = util.promisify(fs.readdir); +const kIgnored = Symbol('ignored'); +const kDirname = Symbol('dirname'); -/** - * Helpers - */ +const normalize = filepath => path.normalize(filepath.split(/[\\/]/).join(path.sep)); -const GARBAGE_REGEX = /(?:Thumbs\.db|\.DS_Store)$/i; -const isGarbageFile = (file, regex = GARBAGE_REGEX) => regex.test(file); -const filterGarbage = (file, regex) => !isGarbageFile(file, regex); -const isValidDir = (cwd, dir, empty) => { - return !empty.includes(dir) && startsWith(dir, cwd) && isDirectory(dir); -}; +const isIgnored = (dirname, options = {}) => { + const basedir = normalize(dirname); + const parent = path.dirname(basedir); -const deleteDir = async (dirname, options = {}) => { - if (options.dryRun !== true) { - return new Promise((resolve, reject) => { - rimraf(dirname, { ...options, glob: false }, err => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }) + if (parent === basedir) { + return true; + } + + if (typeof options.ignore === 'function') { + return options.ignore(basedir); } -}; -const deleteDirSync = (dirname, options = {}) => { - if (options.dryRun !== true) { - return rimraf.sync(dirname, { ...options, glob: false }); + if (options.ignore === false) { + return false; } + + const cache = deleteEmpty.cache; + const key = options.ignore ? [].concat(options.ignore).flat().join(',') : kIgnored; + const regex = cache[key] || (cache[key] = systemPathRegex(parent, options.ignore, options)); + return regex.test(basedir); }; -const deleteEmpty = (cwd, options, cb) => { - if (typeof cwd !== 'string') { - return Promise.reject(new TypeError('expected the first argument to be a string')); +const deleteEmpty = async (basedir, options = {}) => { + if (!path.isAbsolute(basedir)) { + basedir = path.resolve(basedir); } - if (typeof options === 'function') { - cb = options; - options = null; + const opts = { [kDirname]: basedir, ...options, glob: false }; + const isGarbage = options.isJunk || junk.is; + const del = options.dryRun ? async () => undefined : util.promisify(rimraf); + const state = { deleted: [], children: 0 }; + const pending = new Set(); + + const push = promise => { + const p = promise.then(() => pending.delete(p)); + pending.add(p); + }; + + const isRoot = () => normalize(opts[kDirname]) === normalize(basedir); + const isInsideRoot = () => !isRoot() && startsWith(basedir, opts[kDirname]) && basedir !== opts[kDirname]; + + if (isIgnored(basedir, options)) { + return state; } - if (typeof cb === 'function') { - return deleteEmpty(cwd, options) - .then(res => cb(null, res)) - .catch(cb); + if (fs.existsSync(path.join(basedir, '.gitkeep'))) { + return state; } - const opts = options || {}; - const dirname = path.resolve(cwd); - const onDirectory = opts.onDirectory || (() => {}); - const empty = []; + return new Promise((resolve, reject) => { + fs.readdir(basedir, { withFileTypes: true }, async (err, files) => { + if (err) { + reject(err); + return; + } - const remove = async filepath => { - let dir = path.resolve(filepath); + for (const file of files) { + file.path = path.resolve(basedir, file.name); - if (!isValidDir(cwd, dir, empty)) return; - onDirectory(dir); + if (isIgnored(file.path)) { + continue; + } - let files = await readdir(dir); + if (isGarbage(file.name)) { + if (options.dryRun !== true) push(del(file.path, opts)); + continue; + } - if (isEmpty(dir, files, empty, opts)) { - empty.push(dir); + if (file.isFile() || file.isSymbolicLink()) { + state.children++; + continue; + } - await deleteDir(dir, opts); + if (file.isDirectory()) { + state.children++; - if (opts.verbose === true) { - console.log(colors.red('Deleted:'), path.relative(cwd, dir)); - } + const promise = deleteEmpty(file.path, opts).then(s => { + state.deleted.push(...s.deleted); + state.children += s.children === 0 ? -1 : s.children; + return file; + }); - if (typeof opts.onDelete === 'function') { - await opts.onDelete(dir); + push(promise); + } } - return remove(path.dirname(dir)); - } + await Promise.all(pending); - for (const file of files) { - await remove(path.join(dir, file)); - } + if (state.children === 0) { + if (((isRoot() && options.force === true || options.deleteRoot === true) || isInsideRoot())) { + const promise = del(basedir, opts).then(() => { + state.deleted.push(basedir); + }); - return empty; - }; + push(promise); + } + } - return remove(dirname); + Promise.all(pending).then(() => resolve(state)).catch(reject); + }); + }); }; -deleteEmpty.sync = (cwd, options) => { - if (typeof cwd !== 'string') { - throw new TypeError('expected the first argument to be a string'); +export const deleteEmptySync = (basedir, options = {}) => { + if (!path.isAbsolute(basedir)) { + basedir = path.resolve(basedir); } - const opts = options || {}; - const dirname = path.resolve(cwd); - const deleted = []; - const empty = []; - - const remove = filepath => { - let dir = path.resolve(filepath); - - if (!isValidDir(cwd, dir, empty)) { - return empty; - } - - let files = fs.readdirSync(dir); + const opts = { [kDirname]: basedir, ...options, glob: false }; + const delSync = options.dryRun ? () => {} : rimraf.sync; + const state = { deleted: [], children: 0 }; + const isGarbage = options.isJunk || junk.is; - if (isEmpty(dir, files, empty, opts)) { - empty.push(dir); + const isRoot = () => opts[kDirname] === basedir; + const isInsideRoot = () => !isRoot() && startsWith(basedir, opts[kDirname]) && basedir !== opts[kDirname]; - deleteDirSync(dir, opts); + if (isIgnored(basedir, options)) { + return state; + } - if (opts.verbose === true) { - console.log(colors.red('Deleted:'), path.relative(cwd, dir)); - } + if (fs.existsSync(path.join(basedir, '.gitkeep'))) { + return state; + } - if (typeof opts.onDelete === 'function') { - opts.onDelete(dir); - } + for (const file of fs.readdirSync(basedir, { withFileTypes: true })) { + file.path = path.resolve(basedir, file.name); - return remove(path.dirname(dir)); + if (isIgnored(file.path)) { + continue; } - for (let filepath of files) { - remove(path.join(dir, filepath)); + if (isGarbage(file.name)) { + if (options.dryRun !== true) delSync(file.path, opts); + continue; } - return empty; - }; - remove(dirname); - return empty; -}; - -/** - * Return true if the given `files` array has zero length or only - * includes unwanted files. - */ - -const isEmpty = (dir, files, empty, options = {}) => { - let filter = options.filter || filterGarbage; - let regex = options.junkRegex; + if (file.isFile() || file.isSymbolicLink()) { + state.children++; + continue; + } - for (let basename of files) { - let filepath = path.join(dir, basename); + if (file.isDirectory()) { + state.children++; - if (!(options.dryRun && empty.includes(filepath)) && filter(filepath, regex) === true) { - return false; + const s = deleteEmpty.sync(file.path, opts); + state.deleted.push(...s.deleted); + state.children += s.children === 0 ? -1 : s.children; } } - return true; -}; -/** - * Returns true if the given filepath exists and is a directory - */ + if (state.children === 0) { + if ((isRoot() && (options.force === true || options.deleteRoot === true)) || isInsideRoot()) { + delSync(basedir, opts); + state.deleted.push(basedir); + } + } -const isDirectory = dir => { - try { - return fs.statSync(dir).isDirectory(); - } catch (err) { /* do nothing */ } - return false; + return state; }; -/** - * Expose deleteEmpty - */ - -module.exports = deleteEmpty; +deleteEmpty.cache = {}; +deleteEmpty.sync = deleteEmptySync; +export default deleteEmpty; diff --git a/lib/is-system-path.js b/lib/is-system-path.js new file mode 100644 index 0000000..d423ebc --- /dev/null +++ b/lib/is-system-path.js @@ -0,0 +1,7 @@ +import path from 'path'; +import systemPathRegex from './system-path-regex.js'; + +export default (dirname, options = {}) => { + const regex = systemPathRegex(path.dirname(dirname), options.systemPaths); + return regex.test(dirname); +}; diff --git a/lib/system-path-regex.js b/lib/system-path-regex.js new file mode 100644 index 0000000..ef60540 --- /dev/null +++ b/lib/system-path-regex.js @@ -0,0 +1,32 @@ +import path from 'path'; +import picomatch from 'picomatch'; +import systemPaths from './system-paths.js'; + +const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v); + +export default (dir, ignore = [], options) => { + if (isObject(ignore)) { + options = ignore; + ignore = null; + } + + if (!ignore) { + ignore = options.ignore || options.systemPaths || []; + } + + const opts = { flags: 'i', basename: true, platform: process.platform, ...options }; + const { names = [], paths = [] } = systemPaths[opts.platform] || {}; + const globs = [...new Set([...paths].concat(ignore || []))].sort(); + + if (names instanceof Set && names.size > 0) { + for (const name of names) { + if (!globs.includes(path.join(dir, name))) { + globs.push(path.resolve(dir, name)); + globs.push(path.join('/', name)); + } + } + } + + const glob = (opts.basename ? '**' : '') + `{${globs.join(',')}}`; + return picomatch.makeRe(glob, opts); +}; diff --git a/lib/system-paths.js b/lib/system-paths.js new file mode 100644 index 0000000..780cd7f --- /dev/null +++ b/lib/system-paths.js @@ -0,0 +1,96 @@ +/** + * Let's try to avoid accidentally deleting empty system folders. + * This is not comprehensive. Pull requests are encouraged to add additional + * patterns, or suggest a better way of doing this. + */ + +export const win32 = { + names: new Set([ + '\\$GetCurrent', + '\\$Recycle.Bin', + 'desktop.ini', + 'Documents and Settings', + 'found.00[0-9]', + 'hiberfil.sys', + 'Page File.sys', + 'pagefile.sys', + 'PerfLogs', + 'Program Files \\(x86\\)', + 'Program Files', + 'ProgramData', + 'Recovery', + 'swapfile.sys', + 'System Volume Information', + 'System32', + 'Users', + 'Windows', + 'Windows[0-9]+Upgrade', + 'WinSxS' + ]), + paths: new Set([ + 'Windows/System32' + ]) +}; + +export const darwin = { + names: new Set([ + '.smbdelete*', + '.TemporaryItems', + 'bootmgr', + 'BOOTNXT', + 'Google', + 'Seagate' + ]), + paths: new Set([ + '/.file', + '/.vol', + '/.VolumeIcon.icns', + '/Applications', + '/bin', + '/cores', + '/etc', + '/home', + '/Library', + '/opt', + '/private', + '/sbin', + '/System', + '/tmp', + '/Users', + '/Users/*', + '/Users/*/{Applications,Creative Cloud Files,Desktop,Documents,Downloads,Library,Movies,Music,Pictures,Public,.Trash}', + '/usr', + '/var', + '/Volumes' + ]) +}; + +export const linux = { + paths: new Set([ + '/bin', + '/boot', + '/dev', + '/etc', + '/home', + '/lib', + '/lost+found', + '/media', + '/mnt', + '/opt', + '/proc', + '/root', + '/run', + '/sbin', + '/srv', + '/sys', + '/tmp', + '/usr', + '/var' + ]) +}; + +export default { + win32, + darwin, + linux: darwin +}; diff --git a/package.json b/package.json index 4a5900e..94915b6 100644 --- a/package.json +++ b/package.json @@ -16,42 +16,67 @@ "license": "MIT", "files": [ "bin", + "lib", "index.js" ], - "main": "index.js", + "type": "module", + "export": "./index.js", "bin": { "delete-empty": "bin/cli.js" }, "engines": { - "node": ">=10" + "node": ">=12" }, "scripts": { "test": "mocha" }, "dependencies": { - "ansi-colors": "^4.1.0", - "minimist": "^1.2.0", + "ansi-colors": "^4.1.1", + "junk": "^3.1.0", + "minimist": "^1.2.5", "path-starts-with": "^2.0.0", - "rimraf": "^2.6.2" + "picomatch": "^2.2.2", + "rimraf": "^3.0.2" }, "devDependencies": { - "@folder/readdir": "^2.0.0", - "gulp-format-md": "^1.0.0", - "mocha": "^3.5.3", - "write": "^1.0.3" + "@folder/readdir": "^3.1.0", + "braces": "^3.0.2", + "gulp-format-md": "^2.0.0", + "mocha": "^8.4.0", + "write": "^2.0.0" }, "keywords": [ + "clean", + "clear", "del", + "delete-empty", "delete", "dir", + "directories", "directory", "empty", "files", "folder", + "fs", + "garbage", + "is-empty", + "junk", + "readdir", + "readdirSync", "recurse", "recursive", - "remove" + "remove-empty-directories", + "remove-empty", + "remove", + "rimraf", + "rm", + "rmdir", + "trash" ], + "update": { + "run": [], + "tasks": [] + }, "verb": { "run": true, "toc": true, @@ -60,7 +85,9 @@ "readme" ], "reflinks": [ - "verb" + "verb", + "options", + "verb-generate-readme" ], "plugins": [ "gulp-format-md" @@ -69,21 +96,12 @@ "list": [ "copy", "delete", - "fs-utils", + "micromatch", "matched" ] }, "lint": { "reflinks": true } - }, - "lintDeps": { - "devDependencies": { - "files": { - "patterns": [ - "tree.js" - ] - } - } } } diff --git a/test/.eslintrc.json b/test/.eslintrc.json new file mode 100644 index 0000000..fef59e4 --- /dev/null +++ b/test/.eslintrc.json @@ -0,0 +1,8 @@ +{ + "extends": [ + "../.eslintrc.json" + ], + "env": { + "mocha": true + } +} diff --git a/test/fixtures/paths/a/.gitkeep b/test/fixtures/paths/a/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/paths/a/aa/.gitkeep b/test/fixtures/paths/a/aa/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/support/copy.js b/test/support/copy.js index 0f543a8..438fd25 100644 --- a/test/support/copy.js +++ b/test/support/copy.js @@ -1,24 +1,31 @@ -'use strict'; +import fs from 'fs'; +import path from 'path'; +import readdir from '@folder/readdir'; +import write from 'write'; -const fs = require('fs'); -const path = require('path'); -const readdir = require('@folder/readdir'); -const write = require('write'); +const copy = async (cwd, destDir) => { + const files = []; -module.exports = async (cwd, destDir) => { - const filter = file => file.name !== '.gitkeep'; - const files = await readdir(cwd, { recursive: true, objects: true, absolute: true, filter }); + const createDest = file => path.join(destDir, path.relative(cwd, file.path)); + const onDirectory = file => { + file.dest = createDest(file); + fs.mkdirSync(file.dest, { recursive: true }); + files.push(file); + }; - for (let file of files) { - let destPath = path.resolve(destDir, path.relative(cwd, file.path)); - if (fs.existsSync(destPath)) continue; + const onFile = file => { + if (file.name === '.DS_Store' || file.name === '.gitkeep') return; - if (file.isDirectory()) { - fs.mkdirSync(destPath, { recursive: true }); - } else { - write.sync(destPath, fs.readFileSync(file.path)); + file.dest = createDest(file); + + if (!fs.existsSync(file.dest)) { + files.push(file); + write.sync(file.dest, fs.readFileSync(file.path)); } - } + }; + await readdir(cwd, { recursive: true, absolute: true, onDirectory, onFile }); return files; }; + +export default copy; diff --git a/test/support/system-dirs.js b/test/support/system-dirs.js new file mode 100644 index 0000000..1b5aed4 --- /dev/null +++ b/test/support/system-dirs.js @@ -0,0 +1,62 @@ +import braces from 'braces'; + +export const win32 = [ + '\\$GetCurrent', + '\\$Recycle.Bin', + 'desktop.ini', + 'Documents and Settings', + 'found.00{0..100}', + 'hiberfil.sys', + 'Page File.sys', + 'pagefile.sys', + 'PerfLogs', + 'Program Files \\(x86\\)', + 'Program Files', + 'ProgramData', + 'Recovery', + 'swapfile.sys', + 'System Volume Information', + 'System32', + 'Users', + 'Windows', + 'Windows{0..20}Upgrade', + 'Windows/System32', + 'WinSxS' +].reduce((acc, dir) => acc.concat(braces.expand(dir)), []); + +export const darwin = [ + '.smbdelete*', + '.TemporaryItems', + 'bootmgr', + 'BOOTNXT', + 'Google', + 'Seagate', + '/.file', + '/.vol', + '/.VolumeIcon.icns', + '/Applications', + '/bin', + '/cores', + '/etc', + '/home', + '/Library', + '/opt', + '/private', + '/sbin', + '/System', + '/tmp', + '/Users', + '/Users/*', + '/Users/*/{Applications,Creative Cloud Files,Desktop,Documents,Downloads,Library,Movies,Music,Pictures,Public,.Trash}', + '/usr', + '/var', + '/Volumes' +].reduce((acc, dir) => acc.concat(braces.expand(dir)), []); + +export const linux = darwin; + +export default { + linux, + darwin, + win32 +}; diff --git a/test/test.js b/test/test.js index 5082ec3..7d37525 100644 --- a/test/test.js +++ b/test/test.js @@ -1,16 +1,23 @@ -'use strict'; - -require('mocha'); -const fs = require('fs'); -const path = require('path'); -const util = require('util'); -const readdir = require('@folder/readdir'); -const assert = require('assert'); -const rimraf = require('rimraf'); -const deleteEmpty = require('..'); -const copy = require('./support/copy'); - -const fixtures = path.join.bind(path, __dirname, 'fixtures'); +import fs from 'fs'; +import path from 'path'; +import util from 'util'; +import readdir from '@folder/readdir'; +import assert from 'assert'; +import _rimraf from 'rimraf'; +import copy from './support/copy.js'; +import dirs from './support/system-dirs.js'; +import deleteEmpty from '../index.js'; + +const parsed = new URL(import.meta.url); +const dirname = path.dirname(parsed.pathname); + +const rimraf = util.promisify(_rimraf); +const systemDirs = dirs[process.platform]; + +const sysFolders = { darwin: 'usr', linux: 'sbin', win32: 'System32' }; +const sysFolder = sysFolders[process.platform] || sysFolders.linux; + +const fixtures = path.join.bind(path, dirname, 'fixtures'); const expected = [ fixtures('temp/a/aa/aaa'), fixtures('temp/a/aa/aaa/aaaa'), @@ -18,30 +25,50 @@ const expected = [ fixtures('temp/c') ]; +const addFixtures = destdir => copy(destdir, fixtures('temp')); +const addFakeSystemPaths = async () => { + for (const dir of systemDirs) { + await fs.promises.mkdir(fixtures('temp', sysFolder, dir), { recursive: true }); + } +}; + const noNested = files => files.filter(file => !/nested/.test(file)); const filter = file => file.isDirectory(); let folders; describe('deleteEmpty', () => { - afterEach(cb => rimraf(fixtures('temp'), cb)); - + before(async () => await rimraf(fixtures('temp'))); beforeEach(async () => { - await copy(fixtures('paths'), fixtures('temp')) - folders = readdir.sync(fixtures('temp/nested'), { filter, recursive: true, absolute: true }); + await addFixtures(fixtures('paths')); + const nested = fixtures('temp/nested'); + folders = readdir.sync(nested, { filter, recursive: true, absolute: true }); + if (!folders.includes(nested)) folders.push(nested); folders.sort(); }); + afterEach(async () => await rimraf(fixtures('temp'))); + describe('promise', cb => { it('should delete the given cwd if empty', () => { - return deleteEmpty(fixtures('temp/b')) - .then(deleted => { - assert(!fs.existsSync(fixtures('temp/b'))) + return deleteEmpty(fixtures('temp/b'), { force: true }) + .then(({ deleted }) => { + assert(!fs.existsSync(fixtures('temp/b'))); + }); + }); + + it('should ignore specified paths', () => { + const ignore = dirname => dirname === fixtures('temp/b'); + + return deleteEmpty(fixtures('temp'), { ignore }) + .then(() => { + assert(fs.existsSync(fixtures('temp/b'))); + assert(!fs.existsSync(fixtures('temp/a/aa/aaa'))); }); }); it('should delete nested directories', () => { return deleteEmpty(fixtures('temp')) - .then(deleted => { + .then(({ deleted }) => { assert(!fs.existsSync(fixtures('temp/a/aa/aaa'))); assert(!fs.existsSync(fixtures('temp/b'))); assert(!fs.existsSync(fixtures('temp/c'))); @@ -50,19 +77,33 @@ describe('deleteEmpty', () => { it('should return the array of deleted directories', () => { return deleteEmpty(fixtures('temp')) - .then(deleted => { + .then(({ deleted }) => { + assert.deepEqual(noNested(deleted).sort(), expected.sort()); + }); + }); + + it('should ignore system paths', async () => { + await addFakeSystemPaths(); + + return deleteEmpty(fixtures('temp')) + .then(async ({ deleted }) => { + for (const dir of systemDirs) { + assert(fs.existsSync(fixtures('temp', sysFolder, dir))); + } + + await rimraf(fixtures('temp', sysFolder)); assert.deepEqual(noNested(deleted).sort(), expected.sort()); - }) + }); }); }); describe('promise - options.dryRun', () => { - it('should not delete the given cwd if empty', () => { + it('should not delete the given cwd if empty when dryRun is true', () => { return deleteEmpty(fixtures('temp/b'), { dryRun: true }) - .then(() => assert(fs.existsSync(fixtures('temp/b')))) + .then(() => assert(fs.existsSync(fixtures('temp/b')))); }); - it('should not delete nested directories', () => { + it('should not delete nested directories when dryRun is true', () => { return deleteEmpty(fixtures('temp'), { dryRun: true }) .then(() => { assert(fs.existsSync(fixtures('temp/a/aa/aaa'))); @@ -72,130 +113,52 @@ describe('deleteEmpty', () => { }); it('should delete deeply nested directories', () => { - return deleteEmpty(fixtures('temp/nested')) - .then(deleted => { - assert.equal(folders.length, deleted.length); + return deleteEmpty(fixtures('temp/nested'), { deleteRoot: true }) + .then(state => { + assert.equal(folders.length, state.deleted.length); }); }); it('should return the array of empty directories', () => { return deleteEmpty(fixtures('temp')) - .then(deleted => { + .then(({ deleted }) => { assert.deepEqual(noNested(deleted).sort(), expected.sort()); }); }); it('should not delete directories when options.dryRun is true', () => { return deleteEmpty(fixtures('temp'), { dryRun: true }) - .then(deleted => { - for (let folder of folders) { + .then(({ deleted }) => { + for (const folder of folders) { assert(fs.existsSync(folder)); } }); }); }); - describe('async', () => { - it('should delete the given cwd if empty', cb => { - deleteEmpty(fixtures('temp/b'), (err, deleted) => { - if (err) { - cb(err); - return; - } - assert(!fs.existsSync(fixtures('temp/b'))); - cb(); - }); - }); - - it('should delete nested directories', cb => { - deleteEmpty(fixtures('temp'), (err, deleted) => { - if (err) { - cb(err); - return; - } - assert(!fs.existsSync(fixtures('temp/a/aa/aaa'))); - assert(!fs.existsSync(fixtures('temp/b'))); - assert(!fs.existsSync(fixtures('temp/c'))); - cb(); - }); - }); - - it('should return the array of deleted directories', cb => { - deleteEmpty(fixtures('temp'), (err, deleted) => { - if (err) { - cb(err); - return; - } - assert.deepEqual(noNested(deleted).sort(), expected.sort()); - cb(); - }); - }); - }); - - describe('async - options.dryRun', () => { - it('should not delete the given cwd if empty', cb => { - deleteEmpty(fixtures('temp/b'), { dryRun: true }, (err, deleted) => { - if (err) { - cb(err); - return; - } - assert(fs.existsSync(fixtures('temp/b'))); - cb(); - }); - }); - - it('should not delete nested directories', cb => { - deleteEmpty(fixtures('temp'), { dryRun: true }, (err, deleted) => { - if (err) { - cb(err); - return; - } - assert(fs.existsSync(fixtures('temp/a/aa/aaa'))); - assert(fs.existsSync(fixtures('temp/b'))); - assert(fs.existsSync(fixtures('temp/c'))); - cb(); - }); - }); - - it('should return the array of empty directories', cb => { - deleteEmpty(fixtures('temp'), { dryRun: true }, (err, deleted) => { - if (err) { - cb(err); - return; - } - - assert.deepEqual(noNested(deleted).sort(), expected.sort()); - cb(); - }); - }); - }); - describe('sync', () => { - it('should delete the given cwd if empty', cb => { - deleteEmpty.sync(fixtures('temp/b')); + it('should delete the given cwd if empty', () => { + deleteEmpty.sync(fixtures('temp/b'), { force: true }); assert(!fs.existsSync(fixtures('temp/b'))); - cb(); }); - it('should delete nested directories', cb => { + it('should delete nested directories', () => { deleteEmpty.sync(fixtures('temp')); assert(!fs.existsSync(fixtures('temp/a/aa/aaa/aaaa'))); assert(!fs.existsSync(fixtures('temp/a/aa/aaa'))); assert(!fs.existsSync(fixtures('temp/b'))); assert(!fs.existsSync(fixtures('temp/c'))); - cb(); }); - it('should return the array of deleted directories', cb => { - var deleted = deleteEmpty.sync(fixtures('temp')); + it('should return the array of deleted directories', () => { + const { deleted } = deleteEmpty.sync(fixtures('temp')); assert.deepEqual(noNested(deleted).sort(), expected.sort()); - cb(); }); }); describe('sync - options.dryRun', () => { it('should not delete the given cwd if empty', () => { - deleteEmpty.sync(fixtures('temp/b'), { dryRun: true }); + deleteEmpty.sync(fixtures('temp/b'), { dryRun: true, force: true }); assert(fs.existsSync(fixtures('temp/b'))); }); @@ -207,10 +170,9 @@ describe('deleteEmpty', () => { assert(fs.existsSync(fixtures('temp/c'))); }); - it('should return the array of empty directories', cb => { - var deleted = deleteEmpty.sync(fixtures('temp'), { dryRun: true }); + it('should return the array of empty directories', () => { + const { deleted } = deleteEmpty.sync(fixtures('temp'), { dryRun: true }); assert.deepEqual(noNested(deleted).sort(), expected.sort()); - cb(); }); }); });