批量改文件名之前,先生成一份可核对的改名清单
用三个虚构文件练习批量改名前的清单核对:预览不改动文件,重复目标与已有目标整批拒绝,正常改名和恢复按名称及内容哈希验收。附完整 Node.js 演示,明确并发、事务与恢复的边界。
执行信息
- 测试环境
- Node.js v22.22.0 / macOS / 本地 CLI 与虚构临时文件
- 输入
- 请查看正文中的输入说明
- 产出
- 请查看正文中的产出说明
- 实测结果
- 请以作者提供的实测记录为准
AgentField 编辑整理|文件名和内容均为虚构演示。本篇在独立临时目录验证清单、冲突拒绝、改名与恢复,不是用户案例或真实资料处理工具。
批量改名最值得先看的是“哪个源文件会变成哪个目标名”。只说成功改了三项,无法发现选错文件、覆盖已有目标或改掉扩展名。先把映射列出来,再用冲突样本验证停止条件。
一、先独立写出清单和期望
本例只把文件名开头的 raw- 换成 ready-,保留扩展名。三个文件的内容分别是 demo alpha、demo beta、demo gamma,每项末尾都有一个换行。正常映射为:
raw-a.txt→ready-a.txt。raw-b.md→ready-b.md。raw-c.txt→ready-c.txt。
先确定四个验收结果,再运行代码:
- 预览:源名、文件内容摘要和改名次数不变,次数为 0。
- 重复目标:把第三项也指向
ready-a.txt,整批拒绝,第一项也不能先改。 - 已有目标:预先放一个虚构
ready-c.txt,整批拒绝,原目标内容保持不变。 - 正常与恢复:三个新名称对应的内容 SHA-256 与原文件一致;按记录反向映射后,名称及摘要回到起点。
二、复制这份完整演示再运行
把下列代码保存为 rename-demo.cjs,在一个专用于练习的空文件夹运行 node rename-demo.cjs。需要 Node.js;编辑实际验证版本为 v22.22.0。无需安装依赖,也不要把真实文件放进演示目录。
脚本在当前工作目录下为三个场景各新建一个随机命名的 rename-demo- 临时目录,只处理自己创建的普通文件,不递归、不接受真实目录参数、不处理符号链接。每个场景先记录预览,再自动尝试执行;这不是等待人工确认的生产工具。正常场景继续演示恢复,结束后清理临时文件,JSON 输出保留前后摘要。
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { deepStrictEqual } = require('node:assert/strict');
const fixtures = {
'raw-a.txt': 'demo alpha\n',
'raw-b.md': 'demo beta\n',
'raw-c.txt': 'demo gamma\n'
};
const hash = data => createHash('sha256').update(data).digest('hex');
function snapshot(dir) {
return Object.fromEntries(fs.readdirSync(dir).sort().map(name => {
const file = path.join(dir, name);
if (!fs.lstatSync(file).isFile()) throw new Error('NOT_REGULAR_FILE');
return [name, hash(fs.readFileSync(file))];
}));
}
function validate(dir, map) {
const present = new Set(Object.keys(snapshot(dir)));
const sources = new Set(), targets = new Set();
for (const { from, to } of map) {
// The demo accepts flat lowercase ASCII names only.
if (![from, to].every(n => /^[a-z0-9-]+\.[a-z]+$/.test(n))) {
throw new Error('UNSUPPORTED_NAME');
}
if (sources.has(from)) throw new Error('DUPLICATE_SOURCE');
if (targets.has(to)) throw new Error('DUPLICATE_TARGET');
sources.add(from); targets.add(to);
if (!present.has(from)) throw new Error('SOURCE_MISSING');
if (from === to || present.has(to)) throw new Error('TARGET_EXISTS');
if (path.extname(from) !== path.extname(to)) throw new Error('EXTENSION_CHANGED');
}
}
function runCase(kind, parent) {
const dir = fs.mkdtempSync(path.join(parent, 'rename-demo-'));
let renameOperations = 0;
const apply = map => {
validate(dir, map); // Whole-map validation happens before the first rename.
for (const { from, to } of map) {
fs.renameSync(path.join(dir, from), path.join(dir, to));
renameOperations++;
}
};
let result;
try {
for (const [name, text] of Object.entries(fixtures)) {
fs.writeFileSync(path.join(dir, name), text, { flag: 'wx' });
}
if (kind === 'existing') {
fs.writeFileSync(path.join(dir, 'ready-c.txt'), 'occupied demo\n', { flag: 'wx' });
}
const before = snapshot(dir);
const map = Object.keys(fixtures).map(from => ({
from, to: from.replace(/^raw-/, 'ready-')
}));
if (kind === 'duplicate') map[2].to = 'ready-a.txt';
let previewError = null;
try { validate(dir, map); } catch (e) { previewError = e.message; }
const afterPreview = snapshot(dir);
result = { kind, map, before, previewError, afterPreview, previewRenameOperations: renameOperations };
let applyError = null;
try { apply(map); } catch (e) { applyError = e.message; }
result.applyError = applyError;
result.afterApply = snapshot(dir);
result.applyRenameOperations = renameOperations;
if (kind === 'normal' && !applyError) {
const recordedAfter = result.afterApply;
// Only unchanged demo files may be restored from this in-memory record.
deepStrictEqual(snapshot(dir), recordedAfter);
const reverseMap = map.map(({ from, to }) => ({ from: to, to: from }));
apply(reverseMap);
result.reverseMap = reverseMap;
result.afterRestore = snapshot(dir);
result.totalRenameOperations = renameOperations;
}
} finally {
// Flat cleanup of this newly created demo directory only; no recursion.
for (const name of Object.keys(snapshot(dir))) fs.unlinkSync(path.join(dir, name));
fs.rmdirSync(dir);
}
return { ...result, temporaryDirectoryRemoved: !fs.existsSync(dir) };
}
function runDemo(parent = process.cwd()) {
return ['duplicate', 'existing', 'normal'].map(kind => runCase(kind, parent));
}
if (require.main === module) {
console.log(JSON.stringify({ node: process.version, observedAt: new Date().toISOString(), results: runDemo() }, null, 2));
}
module.exports = { fixtures, runDemo };