232 lines
5.8 KiB
JavaScript
232 lines
5.8 KiB
JavaScript
'use strict'
|
|
|
|
const fs = require('fs')
|
|
const fsp = require('fs').promises
|
|
const path = require('path')
|
|
|
|
// accessSync instead of existsSync: same answer, one syscall fewer.
|
|
function pathExists(p) {
|
|
try {
|
|
fs.accessSync(p)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
|
|
function dedupeDir(base, name) {
|
|
const candidate = path.join(base, name)
|
|
if (!fs.existsSync(candidate)) return candidate
|
|
let n = 2
|
|
while (fs.existsSync(path.join(base, `${name} (${n})`))) n++
|
|
return path.join(base, `${name} (${n})`)
|
|
}
|
|
|
|
// Events are rate limited to ~10/s; `force` pushes the final value through.
|
|
const PROGRESS_MIN_MS = 100
|
|
|
|
function progressGate(emit) {
|
|
let last = 0
|
|
let pending = null
|
|
let timer = null
|
|
const flush = () => {
|
|
timer = null
|
|
if (!pending) return
|
|
last = Date.now()
|
|
const data = pending
|
|
pending = null
|
|
emit(data)
|
|
}
|
|
const push = (data, force) => {
|
|
pending = data
|
|
const wait = PROGRESS_MIN_MS - (Date.now() - last)
|
|
if (force || wait <= 0) {
|
|
if (timer) clearTimeout(timer)
|
|
flush()
|
|
} else if (!timer) {
|
|
timer = setTimeout(flush, wait)
|
|
if (timer.unref) timer.unref()
|
|
}
|
|
}
|
|
push.done = () => {
|
|
if (timer) clearTimeout(timer)
|
|
flush()
|
|
}
|
|
return push
|
|
}
|
|
|
|
// Run `fn` over `items` with at most `limit` in flight, results in order.
|
|
async function mapLimit(items, limit, fn) {
|
|
const out = new Array(items.length)
|
|
let next = 0
|
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
while (next < items.length) {
|
|
const i = next++
|
|
out[i] = await fn(items[i], i)
|
|
}
|
|
})
|
|
await Promise.all(workers)
|
|
return out
|
|
}
|
|
|
|
// What a tree copy will move. Symlinks are recreated, not read. `skipDir`
|
|
// leaves out one directory name (redistributables).
|
|
async function treeSize(root) {
|
|
let files = 0
|
|
let bytes = 0
|
|
// a symlinked root is the whole tree; never measure through it
|
|
let rootStat = null
|
|
try {
|
|
rootStat = await fsp.lstat(root)
|
|
} catch {
|
|
return { files, bytes }
|
|
}
|
|
if (!rootStat.isDirectory()) return { files: 1, bytes: rootStat.isSymbolicLink() ? 0 : rootStat.size }
|
|
const walk = async dir => {
|
|
let entries
|
|
try {
|
|
entries = await fsp.readdir(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isSymbolicLink()) {
|
|
files++
|
|
} else if (entry.isDirectory()) {
|
|
await walk(full)
|
|
} else if (entry.isFile()) {
|
|
files++
|
|
try {
|
|
bytes += (await fsp.stat(full)).size
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
await walk(root)
|
|
return { files, bytes }
|
|
}
|
|
|
|
// Copy a tree with symlinks recreated and permissions carried (copyFile
|
|
// drops both); reports cumulative { done, bytes } through onProgress.
|
|
async function copyTree(src, dst, onProgress) {
|
|
let done = 0
|
|
let bytes = 0
|
|
const gate = onProgress ? progressGate(onProgress) : null
|
|
const tick = () => {
|
|
if (gate) gate({ done, bytes }, false)
|
|
}
|
|
const walk = async (from, to) => {
|
|
let entries
|
|
try {
|
|
entries = await fsp.readdir(from, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
await fsp.mkdir(to, { recursive: true })
|
|
for (const entry of entries) {
|
|
const s = path.join(from, entry.name)
|
|
const d = path.join(to, entry.name)
|
|
if (entry.isSymbolicLink()) {
|
|
let target = null
|
|
try {
|
|
target = await fsp.readlink(s)
|
|
} catch {}
|
|
if (!target) continue
|
|
try {
|
|
await fsp.symlink(target, d)
|
|
} catch {}
|
|
done++
|
|
tick()
|
|
} else if (entry.isDirectory()) {
|
|
await walk(s, d)
|
|
} else if (entry.isFile()) {
|
|
let size = 0
|
|
try {
|
|
size = (await fsp.stat(s)).size
|
|
} catch {}
|
|
try {
|
|
await fsp.copyFile(s, d)
|
|
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
|
|
} catch {}
|
|
done++
|
|
bytes += size
|
|
tick()
|
|
}
|
|
}
|
|
}
|
|
await walk(src, dst)
|
|
if (gate) gate({ done, bytes }, true)
|
|
return { done, bytes }
|
|
}
|
|
|
|
// Delete deepest-first with progress through onProgress; symlinks are
|
|
// unlinked, never followed.
|
|
async function removeTree(root, onProgress) {
|
|
let files = 0
|
|
let dirs = 0
|
|
let bytes = 0
|
|
const gate = onProgress ? progressGate(onProgress) : null
|
|
const tick = () => {
|
|
if (gate) gate({ files, dirs, bytes }, false)
|
|
}
|
|
// a symlinked root is removed as itself, never walked through
|
|
let rootStat = null
|
|
try {
|
|
rootStat = await fsp.lstat(root)
|
|
} catch {
|
|
return { files, dirs, bytes }
|
|
}
|
|
if (!rootStat.isDirectory()) {
|
|
files = 1
|
|
bytes = rootStat.isSymbolicLink() ? 0 : rootStat.size
|
|
try {
|
|
await fsp.unlink(root)
|
|
} catch {}
|
|
if (gate) gate({ files, dirs, bytes }, true)
|
|
return { files, dirs, bytes }
|
|
}
|
|
const walk = async dir => {
|
|
let entries
|
|
try {
|
|
entries = await fsp.readdir(dir, { withFileTypes: true })
|
|
} catch {
|
|
return false
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
await walk(full)
|
|
try {
|
|
await fsp.rmdir(full)
|
|
} catch {}
|
|
dirs++
|
|
tick()
|
|
continue
|
|
}
|
|
let size = 0
|
|
try {
|
|
const st = await fsp.lstat(full)
|
|
size = st.isFile() ? st.size : 0
|
|
} catch {}
|
|
try {
|
|
await fsp.unlink(full)
|
|
} catch {}
|
|
files++
|
|
bytes += size
|
|
tick()
|
|
}
|
|
try {
|
|
await fsp.rmdir(dir)
|
|
dirs++
|
|
} catch {}
|
|
return true
|
|
}
|
|
await walk(root)
|
|
if (gate) gate({ files, dirs, bytes }, true)
|
|
return { files, dirs, bytes }
|
|
}
|
|
|
|
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, progressGate, mapLimit }
|
|
|