246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
import { readdir, stat, readFile, open } from 'fs/promises'
|
||
import { join } from 'path'
|
||
|
||
/**
|
||
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
|
||
* 递归枚举两侧目录(跳过 symlink 防环)、按相对路径对齐、字节级内容判定;
|
||
* 全部 IO 走异步 fs(libuv 线程池),不阻塞主进程事件循环。
|
||
*/
|
||
|
||
/** 单个文件条目的对比状态 */
|
||
export type FolderEntryStatus =
|
||
| 'same'
|
||
| 'semantic-same'
|
||
| 'different'
|
||
| 'left-only'
|
||
| 'right-only'
|
||
| 'unreadable'
|
||
|
||
export interface FolderEntry {
|
||
/** 相对路径(统一 / 分隔,含子目录前缀) */
|
||
rel: string
|
||
status: FolderEntryStatus
|
||
/** 左侧文件字节数;该侧不存在为 null */
|
||
leftSize: number | null
|
||
/** 右侧文件字节数;该侧不存在为 null */
|
||
rightSize: number | null
|
||
/** 超过全量比对上限、仅采样头部判定的近似结果(status 为 same 时可能出现) */
|
||
approximate?: boolean
|
||
}
|
||
|
||
export interface ScanResult {
|
||
/** 全部条目(按相对路径字典序排序) */
|
||
entries: FolderEntry[]
|
||
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
|
||
truncated: boolean
|
||
/** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */
|
||
total: number
|
||
}
|
||
|
||
export interface ScanOptions {
|
||
/** 单侧文件数量上限(默认 10000) */
|
||
maxFiles?: number
|
||
/** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */
|
||
maxContentBytes?: number
|
||
/** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */
|
||
semantic?: SemanticOptions
|
||
}
|
||
|
||
/**
|
||
* 语义判等的依赖注入(folderScan 保持纯逻辑可测,解码与扩展名判定由调用方注入)。
|
||
* 生产环境 decode 走主进程解码 worker(大缓冲不阻塞事件循环),测试注入同步实现。
|
||
*/
|
||
export interface SemanticOptions {
|
||
/** 参与语义判等的单侧文件大小上限 */
|
||
maxBytes: number
|
||
/** 相对路径是否为文本文件(按扩展名判定,与打开文件对话框的清单对齐) */
|
||
isTextFile: (rel: string) => boolean
|
||
/** 解码文件字节为文本(BOM/编码探测由注入实现处理) */
|
||
decode: (buf: Buffer) => Promise<string>
|
||
}
|
||
|
||
/** 语义判等参与文件的大小上限(2MB):覆盖绝大多数文本源码/配置;超大文件维持字节级判定 */
|
||
export const SEMANTIC_MAX_BYTES = 2 * 1024 * 1024
|
||
|
||
/**
|
||
* 语义判等:剔除全部空白(含换行,与字符级对比的判等语义一致)后拼接比较;
|
||
* 大小写敏感(大小写差异在代码中多为实质差异,不忽略)。
|
||
* 换行符 CRLF/LF、行尾空白、缩进、空行数量、排版重排(含跨行重组)均不构成差异。
|
||
*/
|
||
export function semanticEquals(left: string, right: string): boolean {
|
||
const n = (s: string): string => s.replace(/\s+/g, '')
|
||
return n(left) === n(right)
|
||
}
|
||
|
||
export const FOLDER_MAX_FILES = 10000
|
||
export const FOLDER_MAX_CONTENT_BYTES = 10 * 1024 * 1024
|
||
/** 近似判定的采样头部字节数 */
|
||
const SAMPLE_BYTES = 8 * 1024
|
||
|
||
/**
|
||
* 内容比对的受控并发数:单侧缺失/大小不同直接判定(无 IO),
|
||
* 大小一致的条目按此并发分批读取比对(批内 Promise.all,批间串行)。
|
||
* 大目录下避免逐文件串行等待 IO;批内左右两文件也并行,实际排队 IO 约 2 倍并发。
|
||
*/
|
||
export const SCAN_CONCURRENCY = 16
|
||
|
||
/**
|
||
* 手写 BFS 遍历目录树收集普通文件(相对路径统一 / 分隔,跳过 symlink 防环),
|
||
* 凑满 maxFiles 即停止深入:readdir({recursive:true}) 只能枚举完整棵树、无法提前终止,
|
||
* 误选超大目录(如含 node_modules)时枚举本身耗时数秒。
|
||
* 截断后 total 为已遍历部分的文件数下限(剩余子树不再统计)。
|
||
*/
|
||
async function listFiles(
|
||
root: string,
|
||
maxFiles: number
|
||
): Promise<{ files: Map<string, number>; truncated: boolean; total: number }> {
|
||
const rels: string[] = []
|
||
let total = 0
|
||
let truncated = false
|
||
const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }]
|
||
while (queue.length > 0) {
|
||
const { dir, prefix } = queue.shift()!
|
||
// 单个目录不可读(权限/被锁):跳过该目录继续扫描其余部分,不整体失败
|
||
let dirents
|
||
try {
|
||
dirents = await readdir(dir, { withFileTypes: true })
|
||
} catch {
|
||
continue
|
||
}
|
||
for (const d of dirents) {
|
||
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
|
||
if (d.isDirectory()) {
|
||
queue.push({ dir: join(dir, d.name), prefix: prefix === '' ? d.name : `${prefix}/${d.name}` })
|
||
} else if (d.isFile()) {
|
||
total++
|
||
if (rels.length >= maxFiles) {
|
||
truncated = true
|
||
} else {
|
||
rels.push(prefix === '' ? d.name : `${prefix}/${d.name}`)
|
||
}
|
||
}
|
||
}
|
||
// 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性)
|
||
if (truncated) break
|
||
}
|
||
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束);
|
||
// 单文件 stat 失败(被锁/权限)跳过该条目,不整体失败
|
||
const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel))))
|
||
const files = new Map<string, number>()
|
||
rels.forEach((rel, i) => {
|
||
const s = stats[i]
|
||
if (s.status === 'fulfilled') files.set(rel, s.value.size)
|
||
})
|
||
return { files, truncated, total }
|
||
}
|
||
|
||
/** 读取文件头部指定字节数(近似判定采样) */
|
||
async function readHead(path: string, bytes: number): Promise<Buffer> {
|
||
const fh = await open(path, 'r')
|
||
try {
|
||
const buf = Buffer.alloc(bytes)
|
||
const { bytesRead } = await fh.read(buf, 0, bytes, 0)
|
||
return buf.subarray(0, bytesRead)
|
||
} finally {
|
||
await fh.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。
|
||
* 判定规则:单侧缺失 → only;大小一致且 ≤ maxContentBytes → 全量字节比对;
|
||
* 大小不同(≤ 语义上限)或字节不同 → 字节级 different(开启语义判等时二次判等,
|
||
* 剔除空白后一致改判 semantic-same);超过全量比对上限 → 头部 8KB 采样近似判定。
|
||
* 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致);
|
||
* 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理——
|
||
* 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。
|
||
*/
|
||
export async function scanFolders(
|
||
leftDir: string,
|
||
rightDir: string,
|
||
options: ScanOptions = {}
|
||
): Promise<ScanResult> {
|
||
const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES
|
||
const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES
|
||
const semantic = options.semantic
|
||
const left = await listFiles(leftDir, maxFiles)
|
||
const right = await listFiles(rightDir, maxFiles)
|
||
|
||
/** 条目是否参与语义判等:双侧存在、均在大小上限内、文本扩展名 */
|
||
const semanticEligible = (rel: string, lSize: number, rSize: number): boolean =>
|
||
semantic !== undefined &&
|
||
lSize <= semantic.maxBytes &&
|
||
rSize <= semantic.maxBytes &&
|
||
semantic.isTextFile(rel)
|
||
|
||
const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
|
||
// 预分配结果槽位:无 IO 的条目直接定案,需读内容的条目收集为任务并发执行
|
||
const entries: FolderEntry[] = new Array(rels.length)
|
||
const tasks: { idx: number; run: () => Promise<void> }[] = []
|
||
rels.forEach((rel, idx) => {
|
||
const lSize = left.files.get(rel)
|
||
const rSize = right.files.get(rel)
|
||
if (lSize === undefined) {
|
||
entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null }
|
||
} else if (rSize === undefined) {
|
||
entries[idx] = { rel, status: 'left-only', leftSize: lSize, rightSize: null }
|
||
} else if (lSize !== rSize && !semanticEligible(rel, lSize, rSize)) {
|
||
// 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案
|
||
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||
} else if (lSize === rSize && lSize > maxContentBytes) {
|
||
// 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注;
|
||
// 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败
|
||
tasks.push({
|
||
idx,
|
||
run: async () => {
|
||
try {
|
||
const [lHead, rHead] = await Promise.all([
|
||
readHead(join(leftDir, rel), SAMPLE_BYTES),
|
||
readHead(join(rightDir, rel), SAMPLE_BYTES)
|
||
])
|
||
entries[idx] = lHead.equals(rHead)
|
||
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
|
||
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||
} catch {
|
||
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
// 大小一致(≤ 上限)全量字节比对;大小不同且均在语义上限内读两侧全量。
|
||
// 字节 different 且开启语义判等时:同一批读取上接语义判等(避免二次 IO);
|
||
// 单文件读取/解码失败(被锁/权限)标记 unreadable,不整体失败
|
||
tasks.push({
|
||
idx,
|
||
run: async () => {
|
||
try {
|
||
const [lBuf, rBuf] = await Promise.all([
|
||
readFile(join(leftDir, rel)),
|
||
readFile(join(rightDir, rel))
|
||
])
|
||
if (lBuf.equals(rBuf)) {
|
||
entries[idx] = { rel, status: 'same', leftSize: lSize, rightSize: rSize }
|
||
return
|
||
}
|
||
if (semanticEligible(rel, lSize, rSize)) {
|
||
const [lt, rt] = await Promise.all([semantic!.decode(lBuf), semantic!.decode(rBuf)])
|
||
if (semanticEquals(lt, rt)) {
|
||
entries[idx] = { rel, status: 'semantic-same', leftSize: lSize, rightSize: rSize }
|
||
return
|
||
}
|
||
}
|
||
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||
} catch {
|
||
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
|
||
}
|
||
}
|
||
})
|
||
}
|
||
})
|
||
// 受控并发分批执行(批内并行、批间串行;单任务失败已在任务内部降级为 unreadable)
|
||
for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) {
|
||
await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run()))
|
||
}
|
||
return { entries, truncated: left.truncated || right.truncated, total: Math.max(left.total, right.total) }
|
||
}
|