0007 与 0008 是本次改造新产生的决策(原计划 6 条)。它们同样满足「后人会问为什么」:把运行锁改成互斥体、或给库里 27 个函数补上 SupportsShouldProcess,都是看起来更规范的错法。 CHANGELOG.md:把 README 里那 33 行「相对旧版修了什么」整节搬过去,并补上本次改造的记录(11 条修复 + 6 条结构与规范,每条修复都写明现象与现状)。README 那一节换成指针。 README 新增两节:「验收与静态分析」(六个层次、Run-RealSmoke 为什么独立存在、以及那 6 条格式规则默认 Disabled 这个容易漏掉的事实);「口令放在哪里」(默认留空不是遗漏,附迁移与验证命令)。 .markdownlint.json 照 PowerShell 主仓库的实践(default true、行长 240),只把 MD024 从关闭改成「仅同级不重复」,因为变更日志需要重复标题。顺带按 .editorconfig 把 .md 的 BOM 去掉。
8.6 KiB
8.6 KiB
变更日志
面向使用者的变更记录。更早的历史在文末「相对旧版修了什么」一节。
2026-09 强化与重构
本次改造的每一处修复都有实测证据,不是"看起来更规范了"。
修复
| 问题 | 现象 | 现在 |
|---|---|---|
| 源文件是无 BOM 的 UTF-8 | 6/6 个文件在 Windows PowerShell 5.1 上连语法都过不去 —— 5.1 没有 BOM 就按 ANSI 解码源码,中文变乱码、全角问号吃掉引号 | 全仓改存 UTF-8 with BOM;100 个文件在 5.1 与 7 上解析零错 |
带 [CmdletBinding()] 的脚本在 5.1 上,param() 默认值拿不到 $PSScriptRoot |
Backup.ps1 在 5.1 上不传路径参数直接报错 —— 而计划任务恰恰不传 |
8 处默认值移到 param() 之后解析 |
行首方向标记贴在目标上时不被识别(+WindowsTerminal) |
清单 28 条里 27 条被当成"源不存在"静默跳过,退出码仍是 0;只有不写标记的 Scoop 真被备份 |
两种写法都认;干跑从"重打 1 个"变成"重打 11 个" |
| 暂存目录半途失败会留下 junction | %TEMP% 里留下指向真实数据的连接点,而临时目录迟早会被 Remove-Item -Recurse 扫到 |
建暂存的函数自己带 try/catch 自清理,不依赖调用方 |
口令随 -Verbose 落进日志 |
DEBUG 级打印整条命令行,而 7z 只接受命令行口令 | 打印前把 -p 参数换成占位符;新增断言 + 红绿证明 |
manifest.json 先删后移 |
Move-Item 一失败,旧账本已经没了 |
复用原子写;目标存在时用 File.Replace |
| 5.1 上拿不到原子替换 | 三参数 File.Move 是 .NET Core 3.0+ 才有的重载,5.1 上必然退化成"先删后移" |
改用 File.Replace(.NET Framework 上同样可用) |
空目录的 TotalSize 是 $null |
$null / 1GB 得 0,而空间守卫判的是 -gt 0 —— 空间不足时不再拦截 |
补成整数 0 |
| 计划任务与手动运行撞车 | 两边互踩 manifest.json,还会抢同一个 <归档>.tmp |
备份目录上加一把跨进程锁(独占文件句柄,进程崩溃自动释放) |
| 口令文件的出厂默认值指向仓库内 | 一次 git add -A 就把口令提交进版本库 |
默认留空;口令只来自 -Password / 环境变量 / 仓库外的文件 / 交互询问 |
| lab 的 ACL 场景在 5.1 上自爆 | 清理函数偏偏在它要处理的"悬空连接点"场景里被原生命令的 stderr 中断 | 原生命令统一走 Invoke-NativeTolerant |
结构与规范
| 变化 | 说明 |
|---|---|
| 模块源码拆分 | 3152 行、66 个函数的 Common.psm1 → BakNRet/{Public,Private} 一函数一文件 + 薄加载器 + 显式导出白名单;tools/Build-BakNRetModule.ps1 可随时合回单文件 |
| 函数命名 | 16 个公共函数补 BakNRet 前缀、全仓大小写统一。这不是审美:静态分析拓出 Write-Log 与本机某个已装模块重名,后果是导入两个模块时一方的命令被静默遮蔽 |
| 静态分析 | 装上 PSScriptAnalyzer 并全仓重排:706 → 71 条告警。注意那 6 条格式规则默认是 Disabled —— 不带 -Settings 的调用会静默漏掉全部排版问题 |
| 编码与行尾 | .editorconfig(含 charset = utf-8-bom)与 .gitattributes 把约定钉成可执行的文件 |
| 验收入口 | test.ps1 是唯一入口(Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍);tests/Run-RealSmoke.ps1 拿真实清单跑只读冒烟 |
| 术语与决策 | CONTEXT.md 术语表 + docs/adr/ 8 条决策记录 |
相对旧版修了什么(更早的历史)
| 问题 | 旧行为 | 现行为 |
|---|---|---|
Start-Process -PassThru 的 ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null |
压缩明明成功却报"压缩失败",exit 2 → 删档重试 的自愈分支永远不可达 |
用 .NET Process 继承控制台启动,退出码可靠 |
排除模式写成 -x!"路径" |
引号成为模式的一部分,排除对所有条目都失效 | 不再嵌引号;含空格自动转 ?,! 前缀走 -xr! |
解析器用 ; 分隔,清单里写的是 , |
整串被当成一个模式,等于没有排除 | , 与 ; 都支持 |
^"([^"]+)" 贪婪匹配 |
整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按空白分词切出修饰符,再处理引号 |
| 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 |
一个软件里两个同名目录(例如两个 persist) |
静默混成一棵树,两边的数据都错 | 名录改成 Slot 结构,每个 Slot 是归档内的一层目录,同名不再冲突 |
| 清单只能"备份 + 恢复"一把抓 | 想只备份的、只恢复的条目得另开文件 | 行首 + / - 直接标方向,两条路径共用一份清单 |
:: 既是"排除"又是历史别名 |
语义含糊::: 一会儿是排除、一会儿是路径 |
:: 只表示覆盖 Path,排除一律写 :- |
加密只能靠裸标记 @encrypt |
名录里的加密意图没法表达 | :encrypt / :!encrypt / @ Encrypt='$false',名录的 Slot 也能写 Encrypt |
| 排除/追加只能写在清单行里 | 名录里的 Slot 光有路径,规则全堆在清单里 | Exclude / Include / Encrypt 都可以写在 Slot 上,清单按需覆盖 |
! 只能按通配符匹配 |
想按正则排除做不到 | 新增 !re:<正则>(脚本遍历源目录翻译成精确排除项) |
名录路径只支持 %变量% |
scoop prefix xxx 这类动态路径写不出来 |
Path 支持 $( ... ) 子表达式,并在一次运行内缓存求值结果 |
| 名录每解析一个条目就重新 Import 一次 | 同一个文件被反复读取、$( ... ) 被反复执行 |
按内容指纹缓存,一次运行只读一次 |
直接更新已有归档(7z u) |
固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → 7z t 校验 → 原子替换 |
| 没有校验、没有记录 | 中断留下的半个归档会被下次 u 续写;跳过/失败只有一行滚过去的 WARN |
校验 + 原子替换 + manifest.json + 日志文件 |
结尾不 exit |
全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 |
恢复用 -Filter "$baseName.*" |
含 [ ] 的路径会失配 |
精确比较 BaseName,且优先查 manifest |
tar 分支 $LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0 |
$LASTEXITCODE 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 |
三条分支统一走同一个取退出码的封装 |
| 恢复没有干跑 | 直接覆盖 E:\CodeSpace、Edge User Data 这类真实目录 |
-WhatIf / -DryRun / -VerifyOnly / -Only |
manifest.json 的 roots |
记的是软件名,与归档里真实的顶层目录对不上(Edge vs User Data) |
记归档内真实的顶层条目名,并且和归档内容对账过 |
-DryRun / -WhatIf / -VerifyOnly |
仍然写回 manifest.json,违背"不会写入任何文件" |
只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) |
| 孤儿归档 | 只在恢复时列一下;带 -Only 时还会把未选中的归档误报成孤儿,吓得人不敢删 |
备份端也做孤儿审计;-Only / -Skip 时不再误报;+ / - 的条目也算"有主" |
手写目录的 :+ 追加 |
被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录 / 追加项) |
| 软件名录的多目录写法 | 一个软件可以挂多个目录,但目录名不能重复,否则包内混成一棵树 | 改成 Slot 结构:每个 Slot 是包内一层目录,同名目录(两个 persist)不再冲突 |
| 一个条目挂多个目录的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个归档项只解出它自己那棵子树 |
| 归档内路径冲突 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示改 Slot 名 / 归档内相对路径 |
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 |
manifest 的 archive 字段 |
源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 |
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |