From 6324e50df948cffd9521838e437d6435e01e410f Mon Sep 17 00:00:00 2001 From: Toru Nagashima Date: Mon, 5 Sep 2016 05:38:30 +0900 Subject: [PATCH] New: `--include-empty-dirs` option --- README.md | 7 +- src/bin/help.js | 2 + src/bin/index.js | 2 + src/bin/main.js | 1 + src/lib/copy-sync.js | 8 +- src/lib/copy.js | 25 +++++-- src/lib/cpx.js | 175 ++++++++++++++++++++++++++++++------------- test/copy.js | 88 ++++++++++++++++++++++ test/util/util.js | 7 +- test/watch.js | 91 +++++++++++++++++++++- 10 files changed, 341 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 454bf08..2462448 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ Options: -C, --clean Clean files that matches like pattern in directory before the first copying. -L, --dereference Follow symbolic links when copying from them. - -h, --help Print usage information + -h, --help Print usage information. + --include-empty-dirs The flag to copy empty directories which is + matched with the glob. -p, --preserve The flag to copy attributes of files. This attributes are uid, gid, atime, and mtime. -t, --transform A module name to transform each file. cpx lookups @@ -42,7 +44,7 @@ Options: -u, --update The flag to not overwrite files on destination if the source file is older. -v, --verbose Print copied/removed files. - -V, --version Print the version number + -V, --version Print the version number. -w, --watch Watch for files that matches , and copy the file to every changing. ``` @@ -101,6 +103,7 @@ cpx.copy(source, dest, callback) - **options** `{object}` - **options.clean** `{boolean}` -- The flag to remove files that copied on past before copy. - **options.dereference** `{boolean}` -- The flag to follow symbolic links when copying from them. + - **options.includeEmptyDirs** `{boolean}` -- The flag to copy empty directories which is matched with the glob. - **options.preserve** `{boolean}` -- The flag to copy uid, gid, atime, and mtime of files. - **options.transform** `{((filepath: string) => stream.Transform)[]}` -- Functions that creates a `stream.Transform` object to transform each copying file. - **options.update** `{boolean}` -- The flag to not overwrite files on destination if the source file is older. diff --git a/src/bin/help.js b/src/bin/help.js index f6da779..2e31b16 100644 --- a/src/bin/help.js +++ b/src/bin/help.js @@ -28,6 +28,8 @@ Options: directory before the first copying. -L, --dereference Follow symbolic links when copying from them. -h, --help Print usage information. + --include-empty-dirs The flag to copy empty directories which is + matched with the glob. -p, --preserve The flag to copy attributes of files. This attributes are uid, gid, atime, and mtime. -t, --transform A module name to transform each file. cpx lookups diff --git a/src/bin/index.js b/src/bin/index.js index 77b03f3..96643c1 100644 --- a/src/bin/index.js +++ b/src/bin/index.js @@ -17,6 +17,7 @@ const OPTIONS = { c: "command", C: "clean", h: "help", + includeEmptyDirs: "include-empty-dirs", L: "dereference", p: "preserve", t: "transform", @@ -30,6 +31,7 @@ const args = subarg(process.argv.slice(2), { "clean", "dereference", "help", + "include-empty-dirs", "preserve", "update", "verbose", diff --git a/src/bin/main.js b/src/bin/main.js index 067321f..50dc5df 100644 --- a/src/bin/main.js +++ b/src/bin/main.js @@ -93,6 +93,7 @@ module.exports = function main(source, outDir, args) { { transform: mergedTransformFactories, dereference: args.dereference, + includeEmptyDirs: args.includeEmptyDirs, preserve: args.preserve, update: args.update, } diff --git a/src/lib/copy-sync.js b/src/lib/copy-sync.js index 0ab0acb..077f8ff 100644 --- a/src/lib/copy-sync.js +++ b/src/lib/copy-sync.js @@ -8,6 +8,7 @@ const {Buffer} = require("safe-buffer") const fs = require("fs") +const mkdirSync = require("mkdirp").sync const MAX_BUFFER = 2048 /** @@ -66,7 +67,12 @@ module.exports = function copySync(src, dst, {preserve, update}) { } } - copyBodySync(src, dst) + if (stat.isDirectory()) { + mkdirSync(dst) + } + else { + copyBodySync(src, dst) + } fs.chmodSync(dst, stat.mode) if (preserve) { diff --git a/src/lib/copy.js b/src/lib/copy.js index 0005c82..975f93d 100644 --- a/src/lib/copy.js +++ b/src/lib/copy.js @@ -7,6 +7,7 @@ "use strict" const fs = require("fs") +const mkdir = require("mkdirp") const Queue = require("./queue") /** @@ -117,14 +118,28 @@ module.exports = function copy( })) } - q.push(next => copyBody(src, dst, transformFactories, (err) => { - if (err) { - cb(err) + q.push(next => { + if (stat.isDirectory()) { + mkdir(dst, (err) => { + if (err) { + cb(err) + } + else { + next() + } + }) } else { - next() + copyBody(src, dst, transformFactories, (err) => { + if (err) { + cb(err) + } + else { + next() + } + }) } - })) + }) q.push(next => fs.chmod(dst, stat.mode, (err) => { if (err) { cb(err) diff --git a/src/lib/cpx.js b/src/lib/cpx.js index edd58a8..5b1691f 100644 --- a/src/lib/cpx.js +++ b/src/lib/cpx.js @@ -7,7 +7,7 @@ "use strict" const {EventEmitter} = require("events") -const {unlink, unlinkSync, rmdir, rmdirSync} = require("fs") +const fs = require("fs") const { dirname, resolve: resolvePath, @@ -27,6 +27,7 @@ const Queue = require("./queue") const BASE_DIR = Symbol("baseDir") const DEREFERENCE = Symbol("dereference") +const INCLUDE_EMPTY_DIRS = Symbol("include-empty-dirs") const OUT_DIR = Symbol("outDir") const PRESERVE = Symbol("preserve") const SOURCE = Symbol("source") @@ -64,7 +65,7 @@ function normalizePath(path) { * @returns {void} */ function doAllSimply(cpx, pattern, action) { - new Glob(pattern, {nodir: true, silent: true}) + new Glob(pattern, {nodir: !cpx.includeEmptyDirs, silent: true}) .on("match", action.bind(cpx)) } @@ -98,26 +99,33 @@ function doAll(cpx, pattern, action, cb) { } } - new Glob(pattern, {nodir: true, silent: true, follow: cpx.dereference}) - .on("match", (path) => { - if (lastError != null) { - return - } + new Glob( + pattern, + { + nodir: !cpx.includeEmptyDirs, + silent: true, + follow: cpx.dereference, + } + ) + .on("match", (path) => { + if (lastError != null) { + return + } - count += 1 - action.call(cpx, path, (err) => { - count -= 1 - lastError = lastError || err - cbIfEnd() - }) - }) - .on("end", () => { - done = true - cbIfEnd() - }) - .on("error", (err) => { + count += 1 + action.call(cpx, path, (err) => { + count -= 1 lastError = lastError || err + cbIfEnd() }) + }) + .on("end", () => { + done = true + cbIfEnd() + }) + .on("error", (err) => { + lastError = lastError || err + }) } module.exports = class Cpx extends EventEmitter { @@ -136,6 +144,7 @@ module.exports = class Cpx extends EventEmitter { this[SOURCE] = normalizePath(source) this[OUT_DIR] = normalizePath(outDir) this[DEREFERENCE] = Boolean(options.dereference) + this[INCLUDE_EMPTY_DIRS] = Boolean(options.includeEmptyDirs) this[PRESERVE] = Boolean(options.preserve) this[TRANSFORM] = [].concat(options.transform).filter(Boolean) this[UPDATE] = Boolean(options.update) @@ -172,6 +181,14 @@ module.exports = class Cpx extends EventEmitter { return this[DEREFERENCE] } + /** + * The flag to copy empty directories which is matched with the glob. + * @type {boolean} + */ + get includeEmptyDirs() { + return this[INCLUDE_EMPTY_DIRS] + } + /** * The flag to copy file attributes. * @type {boolean} @@ -275,18 +292,38 @@ module.exports = class Cpx extends EventEmitter { assert(cb == null || typeof cb === "function") let lastError = null + let stat = null this[QUEUE].push(next => { - unlink(path, (err) => { - if (err == null) { - this.emit("remove", {path}) - } - + fs.stat(path, (err, result) => { lastError = err + stat = result next() }) }) this[QUEUE].push(next => { - rmdir(dirname(path), () => { + if (stat && stat.isDirectory()) { + fs.rmdir(path, (err) => { + if (err == null) { + this.emit("remove", {path}) + } + + lastError = err + next() + }) + } + else { + fs.unlink(path, (err) => { + if (err == null) { + this.emit("remove", {path}) + } + + lastError = err + next() + }) + } + }) + this[QUEUE].push(next => { + fs.rmdir(dirname(path), () => { next() if (cb != null) { cb(lastError) @@ -331,16 +368,37 @@ module.exports = class Cpx extends EventEmitter { return } - for (const path of searchSync(dest, {nodir: true, silent: true})) { - unlinkSync(path) + for (const path of searchSync( + dest, + { + nodir: !this.includeEmptyDirs, + silent: true, + } + )) { try { - rmdirSync(dirname(path)) + const stat = fs.statSync(path) + if (stat.isDirectory()) { + fs.rmdirSync(path) + } + else { + fs.unlinkSync(path) + } + } + catch (err) { + if (err.code !== "ENOENT") { + throw err + } + } + + try { + fs.rmdirSync(dirname(path)) } catch (err) { if (err.code !== "ENOTEMPTY") { throw err } } + this.emit("remove", {path}) } } @@ -374,7 +432,11 @@ module.exports = class Cpx extends EventEmitter { const srcPaths = searchSync( this.source, - {nodir: true, silent: true, follow: this.dereference} + { + nodir: !this.includeEmptyDirs, + silent: true, + follow: this.dereference, + } ) srcPaths.forEach(srcPath => { const dstPath = this.src2dst(srcPath) @@ -406,6 +468,7 @@ module.exports = class Cpx extends EventEmitter { } const m = new Minimatch(this.source) + let firstCopyCount = 0 let ready = false const fireReadyIfReady = () => { @@ -414,6 +477,31 @@ module.exports = class Cpx extends EventEmitter { } } + const onAdded = (path) => { + const normalizedPath = normalizePath(path) + if (m.match(normalizedPath)) { + if (ready) { + this.enqueueCopy(normalizedPath) + } + else { + firstCopyCount += 1 + this.enqueueCopy(normalizedPath, () => { + firstCopyCount -= 1 + fireReadyIfReady() + }) + } + } + } + const onRemoved = (path) => { + const normalizedPath = normalizePath(path) + if (m.match(normalizedPath)) { + const dstPath = this.src2dst(normalizedPath) + if (dstPath !== normalizedPath) { + this.enqueueRemove(dstPath) + } + } + } + this[WATCHER] = createWatcher( this.base, { @@ -422,31 +510,12 @@ module.exports = class Cpx extends EventEmitter { followSymlinks: this.dereference, } ) + this[WATCHER] - .on("add", (path) => { - const normalizedPath = normalizePath(path) - if (m.match(normalizedPath)) { - if (ready) { - this.enqueueCopy(normalizedPath) - } - else { - firstCopyCount += 1 - this.enqueueCopy(normalizedPath, () => { - firstCopyCount -= 1 - fireReadyIfReady() - }) - } - } - }) - .on("unlink", (path) => { - const normalizedPath = normalizePath(path) - if (m.match(normalizedPath)) { - const dstPath = this.src2dst(normalizedPath) - if (dstPath !== normalizedPath) { - this.enqueueRemove(dstPath) - } - } - }) + .on("add", onAdded) + .on("addDir", onAdded) + .on("unlink", onRemoved) + .on("unlinkDir", onRemoved) .on("change", (path) => { const normalizedPath = normalizePath(path) if (m.match(normalizedPath)) { diff --git a/test/copy.js b/test/copy.js index 1218682..3ee84d1 100644 --- a/test/copy.js +++ b/test/copy.js @@ -170,6 +170,94 @@ describe("The copy method", () => { }) }) + describe("should copy specified empty directories with globs when `--include-empty-dirs` option was given:", () => { + beforeEach(() => { + setupTestDir({ + "test-ws/a/hello.txt": "Hello", + "test-ws/a/b/pen.txt": "A pen", + "test-ws/a/c": null, + }) + }) + afterEach(() => { + teardownTestDir("test-ws") + }) + + /** + * Verify. + * @returns {void} + */ + function verifyFiles() { + assert(content("test-ws/a/hello.txt") === "Hello") + assert(content("test-ws/a/b/pen.txt") === "A pen") + assert(statSync("test-ws/a/c").isDirectory()) + assert(content("test-ws/b/hello.txt") === "Hello") + assert(content("test-ws/b/b/pen.txt") === "A pen") + assert(statSync("test-ws/b/c").isDirectory()) + } + + it("lib async version.", (done) => { + cpx.copy("test-ws/a/**", "test-ws/b", {includeEmptyDirs: true}, (err) => { + assert(err === null) + verifyFiles() + done() + }) + }) + + it("lib sync version.", () => { + cpx.copySync("test-ws/a/**", "test-ws/b", {includeEmptyDirs: true}) + verifyFiles() + }) + + it("command version.", () => { + execCommandSync("\"test-ws/a/**\" test-ws/b --include-empty-dirs") + verifyFiles() + }) + }) + + describe("should not copy specified empty directories with globs when `--include-empty-dirs` option was not given:", () => { + beforeEach(() => { + setupTestDir({ + "test-ws/a/hello.txt": "Hello", + "test-ws/a/b/pen.txt": "A pen", + "test-ws/a/c": null, + }) + }) + afterEach(() => { + teardownTestDir("test-ws") + }) + + /** + * Verify. + * @returns {void} + */ + function verifyFiles() { + assert(content("test-ws/a/hello.txt") === "Hello") + assert(content("test-ws/a/b/pen.txt") === "A pen") + assert(statSync("test-ws/a/c").isDirectory()) + assert(content("test-ws/b/hello.txt") === "Hello") + assert(content("test-ws/b/b/pen.txt") === "A pen") + assert.throws(() => statSync("test-ws/b/c"), /ENOENT/) + } + + it("lib async version.", (done) => { + cpx.copy("test-ws/a/**", "test-ws/b", (err) => { + assert(err === null) + verifyFiles() + done() + }) + }) + + it("lib sync version.", () => { + cpx.copySync("test-ws/a/**", "test-ws/b") + verifyFiles() + }) + + it("command version.", () => { + execCommandSync("\"test-ws/a/**\" test-ws/b") + verifyFiles() + }) + }) + describe("should copy specified files with globs when `--preserve` option was given:", () => { beforeEach(() => { setupTestDir({ diff --git a/test/util/util.js b/test/util/util.js index 6f49740..64ea466 100644 --- a/test/util/util.js +++ b/test/util/util.js @@ -51,7 +51,12 @@ exports.removeFile = function removeFile(path) { */ exports.setupTestDir = function setupTestDir(dataset) { for (const path in dataset) { - writeFile(path, dataset[path]) + if (dataset[path] == null) { + mkdirSync(path) + } + else { + writeFile(path, dataset[path]) + } } } diff --git a/test/watch.js b/test/watch.js index 36f8493..b2dced5 100644 --- a/test/watch.js +++ b/test/watch.js @@ -6,9 +6,11 @@ "use strict" -const {symlinkSync} = require("fs") +const {symlinkSync, statSync} = require("fs") const {resolve: resolvePath} = require("path") const assert = require("power-assert") +const mkdirSync = require("mkdirp").sync +const rimrafSync = require("rimraf").sync const cpx = require("../src/lib") const { setupTestDir, @@ -304,9 +306,9 @@ describe("The watch method", () => { done() }) }) - }); + }) - [ + ;[ { description: "should copy on file added:", initialFiles: {"test-ws/a/hello.txt": "Hello"}, @@ -424,4 +426,87 @@ describe("The watch method", () => { }) }) }) + + describe("should copy it when an empty directory is added when '--include-empty-dirs' option was given:", () => { + beforeEach(() => { + setupTestDir({ + "test-ws/a/hello.txt": "Hello", + "test-ws/a/b/hello.txt": "Hello", + }) + }) + + /** + * Verify. + * @returns {void} + */ + function verifyFiles() { + assert(content("test-ws/b/hello.txt") === "Hello") + assert(content("test-ws/b/b/hello.txt") === "Hello") + assert(statSync("test-ws/b/c").isDirectory()) + } + + it("lib version.", (done) => { + watcher = cpx.watch("test-ws/a/**", "test-ws/b", {includeEmptyDirs: true}) + waitForReady(() => { + mkdirSync("test-ws/a/c") + waitForCopy(() => { + verifyFiles() + done() + }) + }) + }) + + it("command version.", (done) => { + command = execCommand("\"test-ws/a/**\" test-ws/b --include-empty-dirs --watch --verbose") + waitForReady(() => { + mkdirSync("test-ws/a/c") + waitForCopy(() => { + verifyFiles() + done() + }) + }) + }) + }) + + describe("should remove it on destination when an empty directory is removed when '--include-empty-dirs' option was given:", () => { + beforeEach(() => { + setupTestDir({ + "test-ws/a/hello.txt": "Hello", + "test-ws/a/b/hello.txt": "Hello", + "test-ws/a/c": null, + }) + }) + + /** + * Verify. + * @returns {void} + */ + function verifyFiles() { + assert(content("test-ws/b/hello.txt") === "Hello") + assert(content("test-ws/b/b/hello.txt") === "Hello") + assert.throws(() => statSync("test-ws/b/c"), /ENOENT/) + } + + it("lib version.", (done) => { + watcher = cpx.watch("test-ws/a/**", "test-ws/b", {includeEmptyDirs: true}) + waitForReady(() => { + rimrafSync("test-ws/a/c") + waitForRemove(() => { + verifyFiles() + done() + }) + }) + }) + + it("command version.", (done) => { + command = execCommand("\"test-ws/a/**\" test-ws/b --include-empty-dirs --watch --verbose") + waitForReady(() => { + rimrafSync("test-ws/a/c") + waitForRemove(() => { + verifyFiles() + done() + }) + }) + }) + }) })