diff --git a/lib/index.js b/lib/index.js index e840a9dc..d25fd544 100644 --- a/lib/index.js +++ b/lib/index.js @@ -18,6 +18,7 @@ assign(fs, require('./mkdirs')) assign(fs, require('./remove')) assign(fs, require('./json')) assign(fs, require('./move')) +assign(fs, require('./move-sync')) assign(fs, require('./empty')) assign(fs, require('./ensure')) assign(fs, require('./output')) diff --git a/lib/move-sync/__tests__/move-sync.test.js b/lib/move-sync/__tests__/move-sync.test.js new file mode 100644 index 00000000..87e84f87 --- /dev/null +++ b/lib/move-sync/__tests__/move-sync.test.js @@ -0,0 +1,411 @@ +'use strict' + +const fs = require('graceful-fs') +const os = require('os') +const fse = require(process.cwd()) +const path = require('path') +const assert = require('assert') +const rimraf = require('rimraf') + +/* global afterEach, beforeEach, describe, it */ + +function createSyncErrFn (errCode) { + const fn = function () { + const err = new Error() + err.code = errCode + throw err + } + return fn +} + +const originalRenameSync = fs.renameSync +const originalLinkSync = fs.linkSync + +function setUpMockFs (errCode) { + fs.renameSync = createSyncErrFn(errCode) + fs.linkSync = createSyncErrFn(errCode) +} + +function tearDownMockFs () { + fs.renameSync = originalRenameSync + fs.linkSync = originalLinkSync +} + +describe('moveSync()', () => { + let TEST_DIR + + beforeEach(() => { + TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'move-sync') + + fse.emptyDirSync(TEST_DIR) + + // Create fixtures: + fs.writeFileSync(path.join(TEST_DIR, 'a-file'), 'sonic the hedgehog\n') + fs.mkdirSync(path.join(TEST_DIR, 'a-folder')) + fs.writeFileSync(path.join(TEST_DIR, 'a-folder/another-file'), 'tails\n') + fs.mkdirSync(path.join(TEST_DIR, 'a-folder/another-folder')) + fs.writeFileSync(path.join(TEST_DIR, 'a-folder/another-folder/file3'), 'knuckles\n') + }) + + afterEach(done => rimraf(TEST_DIR, done)) + + it('should not move if src and dest are the same', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-file` + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + // assert src not affected + const contents = fs.readFileSync(src, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should rename a file on the same device', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-file-dest` + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should not overwrite the destination by default', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ok(err && err.code === 'EEXIST', 'throw EEXIST') + } + }) + + it('should not overwrite if overwrite = false', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + try { + fse.moveSync(src, dest, {overwrite: false}) + } catch (err) { + assert.ok(err && err.code === 'EEXIST', 'throw EEXIST') + } + }) + + it('should overwrite file if overwrite = true', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + try { + fse.moveSync(src, dest, {overwrite: true}) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should overwrite the destination directory if overwrite = true', function (done) { + // Tests fail on appveyor/Windows due to + // https://github.com/isaacs/node-graceful-fs/issues/98. + // Workaround by increasing the timeout by a minute (because + // graceful times out after a minute). + this.timeout(90000) + + // Create src + const src = path.join(TEST_DIR, 'src') + fse.ensureDirSync(src) + fse.mkdirsSync(path.join(src, 'some-folder')) + fs.writeFileSync(path.join(src, 'some-file'), 'hi') + + const dest = path.join(TEST_DIR, 'a-folder') + + // verify dest has stuff in it + const pathsBefore = fs.readdirSync(dest) + assert(pathsBefore.indexOf('another-file') >= 0) + assert(pathsBefore.indexOf('another-folder') >= 0) + + try { + fse.moveSync(src, dest, {overwrite: true}) + } catch (err) { + assert.ifError(err) + } + + // verify dest does not have old stuff + const pathsAfter = fs.readdirSync(dest) + assert.strictEqual(pathsAfter.indexOf('another-file'), -1) + assert.strictEqual(pathsAfter.indexOf('another-folder'), -1) + + // verify dest has new stuff + assert(pathsAfter.indexOf('some-file') >= 0) + assert(pathsAfter.indexOf('some-folder') >= 0) + done() + }) + + /* + it('should not create directory structure if mkdirp is false', done => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/does/not/exist/a-file-dest` + + // verify dest directory does not exist + assert(!fs.existsSync(path.dirname(dest))) + + fse.move(src, dest, {mkdirp: false}, err => { + assert.strictEqual(err.code, 'ENOENT') + done() + }) + }) + */ + + it('should create directory structure by default', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/does/not/exist/a-file-dest` + + // verify dest directory does not exist + assert(!fs.existsSync(path.dirname(dest))) + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should work across devices', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-file-dest` + + setUpMockFs('EXDEV') + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs('EXDEV') + }) + + it('should move folders', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + // verify it doesn't exist + assert(!fs.existsSync(dest)) + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest + '/another-file', 'utf8') + const expected = /^tails\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should move folders across devices with EISDIR error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('EISDIR') + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs('EISDIR') + }) + + it('should overwrite folders across devices', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + fs.mkdirSync(dest) + + setUpMockFs('EXDEV') + + try { + fse.moveSync(src, dest, {overwrite: true}) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs('EXDEV') + }) + + it('should move folders across devices with EXDEV error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('EXDEV') + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs('EXDEV') + }) + + it('should move folders across devices with EPERM error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('EPERM') + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs('EPERM') + }) + + it('should move folders across devices with ENOTSUP error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('ENOTSUP') + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs('ENOTSUP') + }) + + describe('clobber', () => { + it('should be an alias for overwrite', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + try { + fse.moveSync(src, dest, {clobber: true}) + } catch (err) { + assert.ifError(err) + } + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + }) + + describe.skip('> when trying to a move a folder into itself', () => { + it('should produce an error', () => { + const SRC_DIR = path.join(TEST_DIR, 'test') + const DEST_DIR = path.join(TEST_DIR, 'test', 'test') + + assert(!fs.existsSync(SRC_DIR)) + fs.mkdirSync(SRC_DIR) + assert(fs.existsSync(SRC_DIR)) + + try { + fse.moveSync(SRC_DIR, DEST_DIR) + } catch (err) { + assert(err) + assert(fs.existsSync(SRC_DIR)) + } + }) + }) + + // tested on Linux ubuntu 3.13.0-32-generic #57-Ubuntu SMP i686 i686 GNU/Linux + // this won't trigger a bug on Mac OS X Yosimite with a USB drive (/Volumes) + // see issue #108 + describe('> when actually trying to a move a folder across devices', () => { + const differentDevice = '/mnt' + let __skipTests = false + + // must set this up, if not, exit silently + if (!fs.existsSync(differentDevice)) { + console.log('Skipping cross-device move test') + __skipTests = true + } + + // make sure we have permission on device + try { + fs.writeFileSync(path.join(differentDevice, 'file'), 'hi') + } catch (err) { + console.log("Can't write to device. Skipping moveSync test.") + __skipTests = true + } + + const _it = __skipTests ? it.skip : it + + describe('> just the folder', () => { + _it('should move the folder', () => { + const src = '/mnt/some/weird/dir-really-weird' + const dest = path.join(TEST_DIR, 'device-weird') + + if (!fs.existsSync(src)) { + fse.mkdirpSync(src) + } + + assert(!fs.existsSync(dest)) + + assert(fs.lstatSync(src).isDirectory()) + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ifError(err) + } + assert(fs.existsSync(dest)) + assert(fs.lstatSync(dest).isDirectory()) + }) + }) + }) +}) diff --git a/lib/move-sync/index.js b/lib/move-sync/index.js new file mode 100644 index 00000000..43bd0e0d --- /dev/null +++ b/lib/move-sync/index.js @@ -0,0 +1,116 @@ +'use strict' + +// most of this code was written by Andrew Kelley +// licensed under the BSD license: see +// https://github.com/andrewrk/node-mv/blob/master/package.json +// This is the sync version that somehow follows the same pattern. + +const fs = require('graceful-fs') +const path = require('path') +const copySync = require('../copy-sync').copySync +const removeSync = require('../remove').removeSync +const mkdirpSync = require('../mkdirs').mkdirsSync + +function moveSync (src, dest, options) { + options = options || {} + const overwrite = options.overwrite || options.clobber || false + + if (path.resolve(src) === path.resolve(dest)) return + + mkdirpSync(path.dirname(dest)) + tryRenameSync() + + function tryRenameSync () { + if (overwrite) { + try { + return fs.renameSync(src, dest) + } catch (err) { + if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST' || err.code === 'EPERM') { + removeSync(dest) + options.overwrite = false // just overwriteed it, no need to do it again + return moveSync(src, dest, options) + } + + if (err.code !== 'EXDEV') throw err + return moveSyncAcrossDevice(src, dest, overwrite) + } + } else { + try { + fs.linkSync(src, dest) + return fs.unlinkSync(src) + } catch (err) { + if (err.code === 'EXDEV' || err.code === 'EISDIR' || err.code === 'EPERM' || err.code === 'ENOTSUP') { + return moveSyncAcrossDevice(src, dest, overwrite) + } + throw err + } + } + } +} + +function moveSyncAcrossDevice (src, dest, overwrite) { + const stat = fs.statSync(src) + + if (stat.isDirectory()) { + return moveDirSyncAcrossDevice(src, dest, overwrite) + } else { + return moveFileSyncAcrossDevice(src, dest, overwrite) + } +} + +function moveFileSyncAcrossDevice (src, dest, overwrite) { + const BUF_LENGTH = 64 * 1024 + const _buff = Buffer.alloc(BUF_LENGTH) + + const flags = overwrite ? 'w' : 'wx' + + try { + const fdr = fs.openSync(src, 'r') + const stat = fs.fstatSync(fdr) + const fdw = fs.openSync(dest, flags, stat.mode) + let bytesRead = 1 + let pos = 0 + + while (bytesRead > 0) { + bytesRead = fs.readSync(fdr, _buff, 0, BUF_LENGTH, pos) + fs.writeSync(fdw, _buff, 0, bytesRead) + pos += bytesRead + } + + fs.closeSync(fdr) + fs.closeSync(fdw) + return fs.unlinkSync(src) + } catch (err) { + // may want to create a directory but `out` line above + // creates an empty file for us: See #108 + // don't care about error here + fs.unlinkSync(dest) + // note: `err` here is from the fdr error + if (err.code === 'EISDIR' || err.code === 'EPERM') { + return moveDirSyncAcrossDevice(src, dest, overwrite) + } + throw err + } +} + +function moveDirSyncAcrossDevice (src, dest, overwrite) { + const options = { + overwrite: false + } + + if (overwrite) { + removeSync(dest) + tryCopySync() + } else { + tryCopySync() + } + + function tryCopySync () { + copySync(src, dest, options) + return removeSync(src) + } +} + +module.exports = { + moveSync +}