UNPKG

diff-file-tree

Version:

Compare two file-trees, get a list of changes, then apply left or right

305 lines (281 loc) 8.52 kB
var assert = require('assert') var {basename} = require('path') var streamEqual = require('stream-equal') var {Readable} = require('streamx') var debug = require('debug')('diff-file-tree') var {wrapFS, join, CycleError} = require('./util') exports.diff = async function diff (left, right, opts) { opts = opts || {} var compareContentCache = opts.compareContentCache var seen = new Set() var changes = [] left = wrapFS(left) right = wrapFS(right) await walk('/') return changes async function walk (path) { // get files in folder var [leftNames, rightNames] = await Promise.all([ left.readdir(path), right.readdir(path) ]) // run ops based on set membership var ps = [] debug('walk', path, leftNames, rightNames) leftNames.forEach(name => { if (rightNames.indexOf(name) === -1) { ps.push(addRecursive(join(path, name))) } else { ps.push(diff(join(path, name))) } }) rightNames.forEach(name => { if (leftNames.indexOf(name) === -1) { ps.push(delRecursive(join(path, name))) } else { // already handled } }) return Promise.all(ps) } async function diff (path) { debug('diff', path) if (opts.filter && opts.filter(path)) { return } // stat the entry var [leftStat, rightStat] = await Promise.all([ left.stat(path), right.stat(path) ]) // check for cycles checkForCycle(leftStat, path) checkForCycle(rightStat, path) // both a file if (leftStat.isFile() && rightStat.isFile()) { return diffFile(path, leftStat, rightStat) } // both a dir if (leftStat.isDirectory() && rightStat.isDirectory()) { return walk(path) } // incongruous, remove all in archive then add all in staging await delRecursive(path, true) await addRecursive(path, true) } async function diffFile (path, leftStat, rightStat) { debug('diffFile', path) var isEq = ( (leftStat.size === rightStat.size) && (isTimeEqual(leftStat.mtime, rightStat.mtime)) ) if (!isEq && opts.compareContent) { // try the cache let cacheHit = false if (compareContentCache) { let cacheEntry = compareContentCache[path] if (cacheEntry && cacheEntry.leftMtime === +leftStat.mtime && cacheEntry.rightMtime === +rightStat.mtime) { isEq = cacheEntry.isEq cacheHit = true } } // actually compare the files if (!cacheHit) { let szl = opts.sizeLimit && opts.sizeLimit.maxSize ? opts.sizeLimit.maxSize : 0 if (szl && (leftStat.size > szl || rightStat.size > szl)) { isEq = opts.sizeLimit.assumeEq || false } else { let ls = await left.createReadStream(path) let rs = await right.createReadStream(path) isEq = await new Promise((resolve, reject) => { streamEqual(ls, rs, (err, res) => { if (err) reject(err) else resolve(res) }) }) } } // store in the cache if (compareContentCache && !cacheHit) { compareContentCache[path] = { leftMtime: +leftStat.mtime, rightMtime: +rightStat.mtime, isEq } } } if (!isEq) { changes.push({change: 'mod', type: 'file', path}) } } async function addRecursive (path, isFirstRecursion = false) { debug('addRecursive', path) if (opts.filter && opts.filter(path)) { return } // find everything at and below the current path in staging // they should be added var st = await left.stat(path) if (!isFirstRecursion /* when first called from diff(), dont check for a cycle again */) { checkForCycle(st, path) } if (st.isFile()) { changes.push({change: 'add', type: 'file', path}) } else if (st.isDirectory()) { // add dir first changes.push({change: 'add', type: 'dir', path}) // add children second if (!opts.shallow) { var children = await left.readdir(path) await Promise.all(children.map(name => addRecursive(join(path, name)))) } } } async function delRecursive (path, isFirstRecursion = false) { debug('delRecursive', path) if (opts.filter && opts.filter(path)) { return } // find everything at and below the current path in the archive // they should be removed var st = await right.stat(path) if (!isFirstRecursion /* when first called from diff(), dont check for a cycle again */) { checkForCycle(st, path) } if (st.isFile()) { changes.push({change: 'del', type: 'file', path}) } else if (st.isDirectory()) { // del children first if (!opts.shallow) { var children = await right.readdir(path) await Promise.all(children.map(name => delRecursive(join(path, name)))) } // del dir second changes.push({change: 'del', type: 'dir', path}) } } function checkForCycle (st, path) { if (!st.ino) return // not all "filesystem" implementations we use have inodes (eg Dat) var id = `${st.dev}-${st.ino}-${basename(path)}` // include basename because windows apparently gives dup inodes sometimes if (seen.has(id)) { throw new CycleError(path) } seen.add(id) } } exports.applyRight = async function applyRight (left, right, changes) { left = wrapFS(left) right = wrapFS(right) assert(Array.isArray(changes), 'Valid changes') // copies can be done in parallel var copyPromises = [] // apply changes debug('applyRight', changes) for (let i = 0; i < changes.length; i++) { let d = changes[i] let op = d.change + d.type if (op === 'adddir') { debug('mkdir', d.path) await right.mkdir(d.path) } if (op === 'deldir') { debug('rmdir', d.path) await right.rmdir(d.path) } if (op === 'addfile' || op === 'modfile') { debug('writeFile', d.path) copyPromises.push(left.copyTo(right, d.path)) } if (op === 'delfile') { debug('unlink', d.path) await right.unlink(d.path) } } return Promise.all(copyPromises) } exports.applyRightStream = function applyRightStream (left, right, changes) { left = wrapFS(left) right = wrapFS(right) assert(Array.isArray(changes), 'Valid changes') var stream = new Readable() debug('applyRightStream', changes) var i = 0 var closed = false stream.on('close', () => { debug(`applyRightStream closed on i=${i}`) closed = true }) async function tick () { if (closed) return let d = changes[i] if (!d) return stream.push(null) try { let op = d.change + d.type if (op === 'adddir') { debug('mkdir', d.path) stream.push({op: 'mkdir', path: d.path}) await right.mkdir(d.path) } if (op === 'deldir') { debug('rmdir', d.path) stream.push({op: 'rmdir', path: d.path}) await right.rmdir(d.path) } if (op === 'addfile' || op === 'modfile') { debug('writeFile', d.path) stream.push({op: 'writeFile', path: d.path}) await left.copyTo(right, d.path) } if (op === 'delfile') { debug('unlink', d.path) stream.push({op: 'unlink', path: d.path}) await right.unlink(d.path) } } catch (e) { return stream.destroy(e) } i++ if (i < changes.length) { tick() } else { stream.push(null) } } tick() return stream } exports.applyLeft = async function applyLeft (left, right, changes) { left = wrapFS(left) right = wrapFS(right) assert(Array.isArray(changes), 'Valid changes') // copies can be done in parallel var copyPromises = [] // apply opposite changes, in reverse debug('applyLeft', changes) for (let i = changes.length - 1; i >= 0; i--) { let d = changes[i] let op = d.change + d.type if (op === 'adddir') { debug('rmdir', d.path) await left.rmdir(d.path) } if (op === 'deldir') { debug('mkdir', d.path) await left.mkdir(d.path) } if (op === 'addfile') { debug('unlink', d.path) await left.unlink(d.path) } if (op === 'modfile' || op === 'delfile') { debug('writeFile', d.path) copyPromises.push(right.copyTo(left, d.path)) } } return Promise.all(copyPromises) } function isTimeEqual (left, right) { left = +left right = +right return left === right }