把一套模板文件复制到十几台环境里,再把每台环境里的配置挨个改成对应的IP、端口、数据库地址。这活儿我干过不止一次,前两次手工处理差点把自己搞崩溃——文件多的时候容易漏拷,替换的时候又容易张冠李戴。后来我把「正则替换」和「拷贝」这两个动作拆开理解,组合成一套固定流程,十几台环境的初始化半小时内就能跑完,还不用回去二次检查。这篇就把这套方法论完整拆给你看,包括正则替换在不同工具里的正确姿势、拷贝过程那些容易翻车的隐藏坑,以及把两者串成脚本的完整套路。如果你也经常做配置分发、模板初始化或者批量文件迁移,这篇可以直接抄作业。
1. 为什么"正则替换"和"拷贝"总是一起出现
1.1 我遇到的那次模板分发事故
先讲个真实场景。几年前我需要给一批新环境初始化配置,当时的情况是这样的:有一个标准的模板目录,里面放了应用配置文件、日志配置、数据库连接配置,大概十几个文件。每个环境要不一样的地方不多,差别基本就在IP、端口、数据库名三项。但坏就坏在这三项分布在好几个文件里,手工改很容易漏。
我记得改到第三台环境的时候,发现第二台的数据库端口被我改错了,因为第二台和第三台的端口号刚好只差一位。等我全部改完,第一遍核对就出了三处不一致,有一处日志路径少了环境标识。那天下午基本都在对照文件、检查差异、重新生成,比预想多花了四五个小时。
那之后我意识到一个问题:这类工作的本质不是"改文件",也不是"复制文件",而是根据模板生成定制版本。只要你还在手工做“复制→打开→查找→修改”,那就一定会出错。正确思路是让"拷贝"负责把文件搬到位,让"正则替换"负责把内容改正确,谁也别越界。
1.2 两个动作的分工:正则管内容,拷贝管位置
这个分工听起来像废话,但很多人下意识会把它们混在一起。我见过有人用脚本一大段一大段地拼接文件内容,就是为了把一个配置参数塞进去;也见过有人把整个目录复制了十几份,然后挨个手工打开改配置,硬是没用一次正则替换。
正则表达式的本质,是对文本模式的匹配与改写。它不关心文件在哪,不关心目录结构,只关心一个字符串是否命中某种模式。比如192\.168\.1\.\d+就是一个模式,命中了就能被替换成目标IP。你给它一段文本,它返回一段新文本,整个动作是纯内容层面的。
文件拷贝的本质,是对数据落盘位置的搬运。它不关心文本里写了什么,只关心源位置和目标位置之间的数据一致性。cp -a、scp、rsync都是干这个的。
两者单独拿出来都不难,难的是组合。组合起来之后就形成了一条清晰的流水线:源目录永远是干净模板,目标目录通过正则替换定制。模板不脏,目标不乱,出问题随时可以推倒重来。
1.3 顺序选择:先拷后改 vs 边拷边改
组合顺序上,主流做法是"先拷后改"。也就是先把整个模板目录复制到目标位置,再对目标位置的文件做正则替换。这个顺序的好处是简单、直观、安全——源模板一直没动过,即使替换写错了,重新拷贝一次就可以恢复。
还有一种做法是"边拷边改",更适合单文件分发。比如你从一个接口拿到模板字符串,正则替换之后直接写入目标文件,中间的临时文件都不生成。这种方式省掉了拷贝动作,适合在编程语言里做,比如 Python 的shutil.copyfileobj配合流式替换,本质上是在写入过程中完成替换。
从工程角度看,我倾向于每次从干净模板重新生成目标目录,而不是在目标目录上反复修改。因为反复修改会让目录变得"脏",你不知道上一次改了哪些地方,也不知道残留了什么旧参数。幂等性在分发场景里非常重要——同一份模板,无论跑多少次脚本,生成的结果都应该是确定且一致的那一份。
2. 正则替换的四个实操层次:编辑器、命令行、脚本、API
2.1 VSCode 里的捕获组与逐行替换
日常调试正则,我最常用 VSCode。Ctrl+H打开替换面板,右侧有个.*按钮,点开就是正则模式。这里最容易踩的坑是捕获组语法差异——VSCode 用的是 JavaScript 风格正则,查找时写(\w+),替换时写$1,不是\1。
举个例子:把user_name, user_age这类字段名改成驼峰userName, userAge,VSCode 自带的替换不支持大小写转换,所以没法一步到位。但它很适合做行级操作,比如给所有匹配行加前缀、去掉空行、交换两段文本。我比较常用的几个:
- 给每行加上引号和逗号:查找
^(.+)$,替换为'$1', - 删除所有纯空白行:查找
^\s*$\n,替换为空 - 交换键值位置:查找
(\w+): (\w+),替换为$2: $1
VSCode 里还有一个容易被忽略的功能:在替换结果里用正则写条件结构。比如把形如id=123和id=abc的行分别替换成不同格式,可以用id=(\d+)与id=([a-z]+)分两次替换。编辑器层面的正则应尽量简单,真正复杂的、带状态判断的、要跨文件批量做的,交给脚本更靠谱。
2.2 Notepad++ 的换行符与特殊字符替换
Windows 环境下很多人还在用 Notepad++,它处理换行符和特殊字符比 VSCode 更直接。替换面板下方有一个"查找模式"选项,处理换行符要在"扩展"模式下写\r\n,而不是正则模式下的\n——这两个模式对反斜杠转义的处理不一样,很容易混淆。
典型的场景是,把从 Windows 拷出来的文件的\r\n统一换成 Unix 风格的\n。如果文件是从 Linux 拉下来的,那反过来把\n换成\r\n。混合换行是很恶心的状态,所以我会用正则模式配合(?<!\r)\n这种负向后顾断言,只处理没有跟着\r的裸\n。
还有一个高频操作是清理行首行尾空格。行首空格用^[ \t]+匹配,行尾用[ \t]+$。这里要注意\t在 Notepad++ 的"扩展"模式里是制表符,但在正则模式里也能识别,不要两边混写。
顺便提一句,Word 里的替换符号又是另一套体系:^p是段落标记,^t是制表符,^l是手动换行符。这种不是正则,而是 Word 自己的通配符,用的时候别和正则语法搞混,否则搜半天都替换不了。
2.3 Linux 三剑客:grep、sed、awk 的组合
Linux 下的文本处理绕不开三剑客。先说分工:grep负责查,sed负责改,awk负责按列处理。实际做分发替换时,我会先把三者串起来用。
第一步是用 grep 确认哪些文件命中了要替换的模式,避免改了个寂寞。比如:
grep -rl "192.168.1.10" ./config/-r递归目录,-l只输出文件名。跑完之后你就清楚哪些目标文件需要处理。
第二步是 sed 替换,这是最常用的:
sed -i 's/192\.168\.1\.10/192.168.1.20/g' app.properties注意两点。第一,-i是原地修改,macOS 的 sed 要求写成-i '',否则会报错;第二,分隔符选不好会把命令搞崩,如果替换内容里包含/,可以用|或#做分隔符,例如把日志路径从/var/log/app改成/var/log/app_web01:
sed -i 's|/var/log/app|/var/log/app_web01|g' logback.xml第三步是 awk 处理结构化文本。比如一个 CSV 文件,想把第三列里某个值换成新值:
awk -F',' 'BEGIN{OFS=","} $3=="old" {$3="new"} {print}' data.csv-F','指定列分隔符,BEGIN{OFS=","}保证输出时也是逗号分隔。awk 是按行的状态机思维,适合处理"某列满足条件就改"的场景,这比 sed 的正则还要更贴近业务语义。
2.4 脚本语言兜底:Python 与 PowerShell
编辑器搞不定的场景,比如跨文件批量替换、需要先读后写的复杂逻辑,我会直接用 Python 的re模块。它提供正则编译、搜索、替换、分割全套 API,而且支持后顾断言和命名分组,表达能力比 sed 强很多。
一个很典型的场景是把 MySQL 的下划线字段名转成驼峰,映射关系写在 Python 里:
import re def snake_to_camel(name: str) -> str: return re.sub(r"_([a-z])", lambda m: m.group(1).upper(), name) print(snake_to_camel("user_name")) # userNamePowerShell 也有对应的-replace运算符,语法更接近 .NET 正则。批量替换一个目录下所有文本文件,可以这样写:
Get-ChildItem -Path ./config -Filter *.properties | ForEach-Object { (Get-Content $_.FullName -Raw) -replace '192\.168\.1\.10', '192.168.1.20' | Set-Content $_.FullName -Encoding UTF8 }这里有个和文件拷贝相关的重点:用Get-Content -Raw一次性读入再写回,比逐行读入更稳。逐行处理容易出现换行符被吞、末行丢失的问题,在 Windows 上尤其常见。
3. 拷贝的隐性坑:文件到位之后,内容往往没到位
3.1 cp、rsync、scp 怎么选,参数怎么记
拷贝动作看似基础,但选错命令会在后面给你挖坑。我自己的选择逻辑很简单:
- 本机目录对目录、且要保留软链接、权限、时间戳,用
cp -a - 跨机器、要增量同步或者要排除某些目录,用
rsync -avz - 临时传一两个文件到远程机器,用
scp
最常见的错误是用cp -r代替cp -a。cp -r会把符号链接指向的文件实体整个复制过去,结果目标目录里出现一堆"替身实体",原本的结构全乱了。cp -a则是归档模式,等价于cp -dR --preserve=all,符号链接、权限位、时间戳都保留。
rsync 更复杂一点,但我强烈建议记住一个关键参数组合:rsync -avz --delete source/ target/。--delete的意思是删除目标目录里多余的、源目录已经不存在的东西。如果没有这个参数,目标目录里会残留旧文件——在分发配置的场景里,残留旧配置是最大的隐患,它会悄悄覆盖你新替换的值。
scp 适合临时的跨机传输,但有个小坑:远程路径含空格或者通配符时,要加引号或转义。更严重的坑是网速慢时,大文件传一半中断,本地只得到半个文件,这在后面会单独讲。
3.2 拷过来打不开、0KB、乱码,根因是什么
很多人遇到过这种问题:从别人那里拷贝了一个图片文件,打开却提示 0KB 或文件损坏;拷贝一个文档过来,里面全是乱码。这两类问题我排查下来基本就四个原因。
第一,传输中断但退出码是 0。scp 或者某些网盘传输工具在文件拷了一半时可能静默失败,尤其在小文件、弱网环境下更容易发生。对策是拷贝后用md5sum或者cmp校验一遍。
第二,源文件本身已经损坏。比如源文件在数据库导出时就不完整,拷过来自然打不开。可以在源位置先验证一次,别白费力。
第三,编码不一致。Windows 上很多工具生成的是 GBK/GB2312 编码文件,拖到 Linux 下按 UTF-8 打开就成了天书。这种情况要做的不是"重新拷贝",而是转码:
iconv -f GBK -t UTF-8 source.txt > target.txt第四,Windows 记事本带的 UTF-8 BOM。BOM 是不可见字符\ufeff,文件拷到 Linux 之后脚本读取第一个字段时可能莫名匹配失败,这个在第六节细说。
拷贝完成后第一个动作应该是校验,而不是直接打开看。我会在脚本里顺手加一句:
diff -r template/ target/ >/dev/null && echo "拷贝一致" || echo "存在差异"3.3 "零拷贝"的拷贝,和文件拷贝根本不是一回事
搜索这个标题的时候,会经常看到"零拷贝"这个热词,尤其是 ROS 2、Java NIO 相关的文章。这里必须澄清一下:零拷贝里的"拷贝",指的是数据在内存、内核缓冲区、用户态缓冲区之间的复制次数,不是文件系统层面把文件从 A 目录挪到 B 目录。
零拷贝技术的典型场景是网络传输和高性能 IO。比如sendfile系统调用允许内核直接把一个文件描述符的内容发给 socket,中间不需要在用户态和内核态之间反复搬运数据。JAVA NIO 的FileChannel.transferTo也是在底层用类似的机制。
ROS 2 的零拷贝则更进一步,指进程间共享内存传递大数据,发送方写入共享内存、接收方直接读取,不需要序列化和反序列化,也不存在内存复制。
这个区别要不要搞明白?要。因为如果你在做文件分发,用cp就是最合适的;但如果你在写一个高频接收数据的服务,拼命优化文件拷贝反而方向错了,该考虑的是内存和传输层面的数据搬运方式。
4. 编程语言里的拷贝:深拷贝、浅拷贝与拷贝构造函数
4.1 浅拷贝为什么危险:你改的可能是同一份数据
说完文件拷贝,再来看编程语言里的对象拷贝。这两者最容易被新手混淆,但其实思路相通——文件拷贝只要把字节搬到新位置就行,对象拷贝需要搞清楚"这个对象内部还引用着谁"。
浅拷贝的危险在于:它只复制了对象自身,没有复制对象内部引用的其他对象。比如有一个类,内部维护着一个List,浅拷贝之后新对象和原对象的List是同一个引用。你往新对象的列表里添加一条数据,原对象的列表也变多了——这通常是让人崩溃的 bug 来源。
用个形象的类比:浅拷贝是复印了一张纸条,纸条上写着仓库地址,你拿着纸条找到的还是同一个仓库;深拷贝则是把整个仓库里的货也翻录了一份,拿到的是完全独立的库房。
4.2 深拷贝的几种实现方式:JSON、递归、专用库
不同语言实现深拷贝的手法不一样,我按实际使用频率排个序。
JavaScript 里最省事的是structuredClone,这是后来原生提供的 API,能处理循环引用、日期、Map、Set 等复杂结构。早年网上教的一律是JSON.parse(JSON.stringify(obj)),这个方案有两个致命缺陷:一是对象里有函数、undefined、Symbol时会被直接丢弃,二是遇到循环引用直接抛异常。小对象图快可以用,正经项目还是用structuredClone。
Python 里直接import copy,然后copy.deepcopy(obj)。相对简单,但要留意深拷贝可能很慢,如果对象里有文件句柄、数据库连接这类资源,deepcopy 会把它们也尝试复制,结果通常不理想,需要自己实现__deepcopy__或者在设计层面就避开。
Java 没有内置的通用深拷贝方法,常见做法是手动实现clone,或者用序列化方案,比如ObjectOutputStream写进字节流再读回来。这个方案有前提:对象的所有字段都必须实现Serializable。更可控的做法是干脆不依赖复制,重构业务避免多个对象共享可变引用。
4.3 拷贝构造函数的调用时机与默认陷阱
C++ 里对应的概念是拷贝构造函数。我知道不少初学者搞不清楚它什么时候被调用,这里给个快速清单:用一个对象初始化另一个对象时、函数按值传参时、函数返回对象时、容器插入对象时,都有可能会触发拷贝构造。
默认的拷贝构造函数做的是浅拷贝,对普通成员没问题,但类里只要有裸指针、文件句柄、互斥锁这类资源,浅拷贝就会带来双重释放和悬空指针。比如两个对象的内部指针指向同一块堆内存,析构函数里各自delete一次,程序直接崩溃。
正确的做法是要么实现深拷贝的拷贝构造函数和赋值运算符,要么直接把拷贝禁用:
class Config { public: Config(const Config&) = delete; Config& operator=(const Config&) = delete; };现代 C++ 里更推荐的思路是引入智能指针和移动语义,把资源所有权转移出去,尽量避免手写深拷贝。
4.4 常见语言里的"复制"冷知识
再补充几个平时容易踩到的点。
Python 列表切片newlist = oldlist[:]是浅拷贝,一维列表看起来没问题,二维列表(嵌套列表)时内层列表仍然是共享的。要复制嵌套结构,还是得copy.deepcopy。
Java 数组的clone()对对象数组也是浅拷贝,数组里存的是引用,不是对象本身。
C# 里两个BitmapData对象之间做全量拷贝,类似memcpy的操作要特别注意Stride(行对齐)的问题。位图一行的字节数不是简单等于Width * BytesPerPixel,为了内存对齐会有填充。直接按整个缓冲区长度的memcpy很可能把填充字节也复制过去,导致图像错位。正确做法是按行拷贝,每行长度取Stride,逐行写入新缓冲区。这类"看似简单、细节要命"的问题,只有真正写过大块数据复制的人才会意识到。
5. 一套完整实践:模板目录分发 + 正则替换的自动化脚本
5.1 Bash 版本:cp -a 打底,sed 精确替换
如果你不想引入编程语言,纯 Bash 也能完成这整套流程。假设模板目录是./template,要生成web01、web02、web03三个环境的配置,每个环境的差异点是 IP 地址和日志路径。
#!/bin/bash TPL_DIR="./template" OUTPUT_ROOT="./output" mkdir -p "$OUTPUT_ROOT" declare -A IP_MAP=( [web01]="192.168.1.10" [web02]="192.168.1.20" [web03]="192.168.1.30" ) for env in "${!IP_MAP[@]}"; do target="$OUTPUT_ROOT/$env" rm -rf "$target" cp -a "$TPL_DIR" "$target" # 替换 IP sed -i "s/192\.168\.1\.10/${IP_MAP[$env]}/g" "$target"/config/*.properties # 替换日志路径 sed -i "s|/var/log/app|/var/log/app_$env|g" "$target"/config/logback.xml done几个关键点:
rm -rf加上cp -a是为了保证幂等,每次生成都是干净目录。sed -i后面直接跟多个文件没问题,但要注意 glob 没匹配到文件时会报错。稳妥的做法是先判断目录里是否有对应文件。- 这里的
IP_MAP用关联数组维护环境与 IP 的映射,比手工写死更清晰,新增环境只加一行映射就够。 - 替换时 IP 里的点号必须转义成
\.,否则正则里的.会匹配任意字符,把不该替换的地方也动了。
5.2 Python 版本:copytree 配合 re.sub,事情变得可维护
Bash 版本适合一次性任务,如果这套分发流程要反复用、要加各种逻辑,我还是推荐用 Python 写。
import re import shutil from pathlib import Path TPL = Path("./template") OUTPUT_ROOT = Path("./output") ENV_CONFIG = { "web01": {"ip": "192.168.1.10", "port": "8081"}, "web02": {"ip": "192.168.1.20", "port": "8082"}, } def rebuild_and_replace(env: str, cfg: dict): target = OUTPUT_ROOT / env if target.exists(): shutil.rmtree(target) shutil.copytree(TPL, target) for cf in target.rglob("*.properties"): content = cf.read_text(encoding="utf-8") content = re.sub(r"192\.168\.1\.10", cfg["ip"], content) content = re.sub(r"(?<=server\.port=)\d+", cfg["port"], content) cf.write_text(content, encoding="utf-8") for env, cfg in ENV_CONFIG.items(): rebuild_and_replace(env, cfg)这里面的rglob("*.properties")会递归匹配所有 properties 文件,不需要自己手动列文件清单。re.sub的第一个参数是模式,第二个是要替换成的字符串。我在这里用了(?<=server\.port=)\d+这种后顾断言,意思只替换server.port=后面的数字,不会误伤其他位置出现的端口号。这类"上下文相关替换"正是正则比普通字符串替换值钱的地方。
另一个要注意的点是read_text和write_text的编码参数。如果模板文件是 UTF-8 就固定用utf-8,写回时保持一致。如果文件带 BOM,要用utf-8-sig才能正确去掉/写入 BOM,这个处理不当后面会非常痛苦。
5.3 参数化配置表:一次处理多台机器的差异
脚本最简单但最有效的优化,是把所有环境差异从代码里抽出来,放到一个单独的映射表里。可以是 Python 里的dict,也可以是一个 CSV 或 YAML 文件。
我自己的习惯是维护一张 CSV,第一列是环境名,后面几列分别对应 IP、端口、日志路径、数据库地址。脚本读取这张表,对每一行做一遍"拷贝 + 替换"。这样新增一部机器、改一个端口,都只需要改表数据,不需要碰脚本逻辑。
这种参数化的思路还能应对一种更高频的需求:同一个模板要分发到多个用途不同目录,每个目录只需要替换其中某几个参数。拿 SQL Server 拷表这种场景来说,有时候你导出的建表脚本里写死了旧库名,新库名要换掉,同时表名可能还要加前缀,这时用正则替换比手工逐条改要可靠得多。MySQL 字段下划线转驼峰也是一样的道理,先写好映射关系再批量处理,能省掉大量重复劳动。
5.4 执行前后的自查:diff 比对与备份回滚
自动化脚本可以快,但不能盲目快。我每写一个分发脚本,都会在脚本里内置一套自查步骤。
第一是产物差异检查。生成完所有环境目录后,跑一遍diff -r对比模板和各环境目录,确认差异只存在于预期替换的参数。如果某个环境多了文件、少了文件夹,这一步能立刻暴露。
diff -rq template output/web01-q参数只输出哪些文件不同,不输出完整差异内容,检查起来更高效。
第二是占位符残留检查。如果模板里有些参数没有被替换到,运行时会保留成{{IP}}这种占位符。脚本结束后用 grep 搜一遍所有产物:
grep -rn "{{" output/ || echo "没有残留占位符"第三是备份与回滚。分发脚本在执行前把上一次的产物目录重命名成output_backup_时间戳,一旦新版本跑崩了,直接把备份目录挪回来。放到 Crontab 里定时执行也没问题。
6. 我反复踩过的五个坑,以及现在的规避习惯
6.1 贪婪匹配误伤后续所有相同文本
正则里最容易翻车的就是贪婪匹配。.*默认会匹配到尽可能长的字符串,这在替换文本时经常引发灾难。比如你要把 HTML 里的标签去掉,写了re.sub(r"<.*>", "", html),结果<a>link</a>会被整个删掉,而不是只删标签、保留link。
正确写法之一是把.*换成.*?非贪婪模式,但更稳妥的是用排除字符集:<[^>]*>,意思匹配一对尖括号、中间不含其他尖括号的字符。这个坑我踩了不止一次,现在的习惯是:任何批量替换脚本上线前,先拿一小段样例文本跑一遍,确认匹配范围正确再全量执行。正则里写.*的时候多问自己一句:这里到底要匹配到什么为止。
6.2 CRLF/LF 换行符被替换成裸的 \r\n
有一次分发脚本跑完,目标环境启动应用直接报配置解析错误,排查了半天发现是 properties 文件的换行符从 LF 变成了 CRLF,导致最后一个配置项后面多了一个不可见的\r字符。
这个坑在 Windows 上操作文件、或者用 Python 以文本模式读写文件时最容易出现。Python 的open函数默认 newline 模式会做换行符转换,如果不注意,读入的\n可能被写成\r\n。解决办法是在处理配置文件时,用二进制模式或者显式指定newline=''读写。
Bash 环境下,清理混入的行尾\r可以用这样一句:
sed -i 's/\r$//' app.properties现在我在脚本里会明确统一换行风格,要么全 LF,要么全 CRLF,绝不允许混着来。
6.3 UTF-8 BOM 让首行替换失效
BOM 是另一个隐形的坑。Windows 下很多编辑器保存文件时会自动写入一个\ufeff头字节,它在文本里看不见,但正则匹配^的时候,BOM 会挡住行的开头,导致首行内容无法命中。
最典型的场景是:配置文件第一行是server.port=8080,脚本正则写(?m)^server\.port=\d+,在 Linux 上执行却怎么都替换不了。用xxd看一下文件头才发现多了ef bb bf三个字节。
去掉 BOM 的办法很多,最粗暴的是:
sed -i '1s/^\xEF\xBB\xBF//' filePython 读写时,如果文件本身带 BOM,用utf-8-sig编码读入自动剥掉 BOM,写回时想保留 BOM 也用utf-8-sig。这里的原则是:模板文件统一不带 BOM,分发脚本统一按 UTF-8 无 BOM 处理,可以减少九成以上的编码问题。
6.4 把"正则项"当成"正则表达式"
这个纯属术语混淆,但确实会在团队里引发乌龙。"正则项"是机器学习里用来约束模型复杂度的一个惩罚项,比如 L1、L2 正则化里的那个lambda * ||w||^2;而"正则表达式"是文本匹配的一种语法。有人会把模型配置里regularization相关的字段拿去套正则替换,结果当然牛头不对马嘴。
遇到这种问题,我通常建议团队在命名上做好区分:代码变量名里用regex_pattern表示正则模式,用reg_weight表示正则项权重,别都简写成一个reg。虽然是个小细节,但确实能省掉不少沟通成本。
6.5 拷贝大目录时忽略了软链接和权限位
最后一个坑,也是我最早踩的坑:用cp -r拷贝了一个包含大量软链接的应用目录,结果目标目录里所有软链接都变成了实体文件。目录膨胀、链接失效不说,还有几个脚本依赖的符号链接结构直接断了。
现在的做法是统一用cp -a或者rsync -a,并且在大目录拷贝前先看一眼目录结构里有哪些链接和特殊文件:
find . -type l -lsrsync 场景下还有一个额外细节,就是-a选项已经包含了-l(保留符号链接)、-p(保留权限)、-t(保留时间戳),不需要再手动叠加。如果你用 rsync 同步带--delete的目标目录,建议先干跑一次--dry-run看输出,确认没有误删文件再真正执行。我是被一次 --delete 误删教训过的,从那以后,凡涉及删除逻辑,一律先 dry-run。