清单格式:两种写法(软件名 / 手写目录)都支持 :+ 追加与 :- 排除;名录改用对象数组并逐条介绍目录
需求
- 支持两种条目写法:1) 直接写软件名录里的软件名;2) 用户手写目录。
- 两种写法都必须支持追加(:+)与排除(:-)。
- 软件名录要改进:scoop 合并成"一个软件 + 一个目录数组"。
- 运行时要把"分别是哪些目录、每个目录是干什么的、排除/追加的理由"讲清楚。
实现
- 名录(SoftwareCatalog.psd1 / Get-SoftwareCatalog)
* 一个软件挂多个目录时写成**对象数组**:@{ Path = '...'; Description = '...' };
也接受纯字符串数组与旧的 @{ Dirs = ... } / @{ Variants = ... }。
* 目录说明(Description)一路带到运行日志里。
* "声明了但当前不存在"的目录不再被丢掉:备份跳过,恢复仍然知道它该回到哪个位置。
* scoop 合并成一个数组条目(%UserProfile%\scoop\persist + %UserProfile%\.config\scoop);
ScoopApps-persist 保持独立条目 —— 它和前者末级名同为 persist,并进同一个归档会在包里撞名。
- 解析与解析结果(ConvertFrom-BackupListLine / Resolve-BackupEntry)
* `:+` 以前只对"软件名且能解析出目录"的写法生效,**手写目录的 :+ 会被整段丢掉**;
现在统一生效,且 :+ 后面写软件名会按名录展开。
* 行尾 `# 说明` 解析成 Comment,运行时打印。
- 归档与恢复
* 同一条目里两个同名目录:打包前明确报错(退出码 1),不再静默混成一棵树。
(7z 命令行没有"入库改名"的能力,归档内顶层名只能是文件系统上的那个名字。)
* 恢复时每个源只解出**它自己那棵子树**,不会再往别的父目录里复制兄弟目录。
- 可解释性
* 新增 Write-BackupEntryPlan:打包前打印条目的目录(含来源与介绍)以及排除/追加的出处;
Restore.ps1 同样打印"哪棵子树还原到哪、会新建还是覆盖"。
- 孤儿归档审计修正:判据只看当前清单,不再把 manifest 的历史记录当成"已知"。
否则"条目被合并/改名后留下的旧归档"会被历史记录遮住,永远不会报警。
验证
- Pester 84 项、零依赖单元 49 项、端到端 23 项,全部通过。
- 真实机器:合并后的 scoop.7z 233 MB / 29478 项 / 7z t 通过,manifest.roots=[persist|scoop];
恢复演练 26982/26982 逐字节一致(.ssh、legendary、Aria 同批通过)。
- 迁移提醒:scoop-config.7z 与 scoop-persist.7z 已无清单条目指向,会出现在孤儿审计里;
确认 scoop.7z 无误后可以自行删除。
This commit is contained in:
1 parent
e114cae8c8
commit
43fa4e52dd
10 files changed
+1083
-339
No files matched your search
@@ -50,7 +50,7 @@
|
||||
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) |
|
||||
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
|
||||
| `logs/` | 每次运行的日志(已 gitignore) |
|
||||
| `tests/` | 测试:Pester 套件、零依赖套件、端到端验收、真实归档恢复演练 |
|
||||
| `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 |
|
||||
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
|
||||
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
|
||||
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
|
||||
@@ -59,28 +59,44 @@
|
||||
|
||||
```powershell
|
||||
@{
|
||||
FooClolor = 'C:\Programs\FooClolor'
|
||||
Kazumi = '%AppData%\com.example\Kazumi'
|
||||
'scoop-config' = '%UserProfile%\.config\scoop' # 含 - 或 . 的键必须加引号
|
||||
'.ssh' = '%UserProfile%\.ssh'
|
||||
# 1) 一个目录,直接写字符串
|
||||
FooClolor = 'C:\Programs\FooClolor'
|
||||
|
||||
# 2) 一个目录 + 介绍(运行时会打印出来,推荐)
|
||||
Kazumi = @{
|
||||
Path = '%AppData%\com.example\Kazumi'
|
||||
Description = 'Kazumi 的观看记录与设置'
|
||||
}
|
||||
|
||||
# 3) 一个软件 = 多个目录:写成对象数组,每个目录各自带说明
|
||||
scoop = @(
|
||||
@{
|
||||
Path = '%UserProfile%\scoop\persist'
|
||||
Description = 'scoop 各应用的持久化数据(重装应用就会丢)'
|
||||
}
|
||||
@{
|
||||
Path = '%UserProfile%\.config\scoop'
|
||||
Description = 'scoop 自身的配置'
|
||||
}
|
||||
)
|
||||
|
||||
# 含 - 或 . 的键必须加引号
|
||||
'.ssh' = @{ Path = '%UserProfile%\.ssh'; Description = 'SSH 私钥(不可再生)' }
|
||||
}
|
||||
```
|
||||
|
||||
**含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报
|
||||
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。
|
||||
要点:
|
||||
|
||||
两个便利特性:
|
||||
|
||||
1. **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。
|
||||
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。
|
||||
2. **同名目录在多处**时显式列出,所有位置都会打进同一个归档:
|
||||
|
||||
```powershell
|
||||
ImHex = @{
|
||||
Path = 'D:\Hex\ImHex'
|
||||
Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex')
|
||||
}
|
||||
```
|
||||
- **含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报
|
||||
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。
|
||||
- 数组元素也接受**纯字符串**(`scoop = @('D:\a', 'D:\b')`),以及旧的
|
||||
`@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法 —— 三种都能用。
|
||||
- **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道
|
||||
"这块内容原本该回到哪个位置",这正是恢复要用的。
|
||||
- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。
|
||||
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。
|
||||
- **一个软件里不能有两个同名目录**(例如两个 `persist`):归档内的顶层名就是目录名,
|
||||
那样会在包里混成一棵树。脚本会明确报错(退出码 1)让你拆成两个条目。
|
||||
|
||||
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
|
||||
|
||||
@@ -94,15 +110,15 @@
|
||||
## BackupList.txt 语法
|
||||
|
||||
```text
|
||||
<软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||
<软件名 或 手写目录> [ :+ <再追加一个目录/软件名> ... ] [ :- <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||
```
|
||||
|
||||
三种写法可以混用:
|
||||
### 两种写法(混用没问题)
|
||||
|
||||
| 写法 | 说明 |
|
||||
| --- | --- |
|
||||
| `FooClolor` | 软件名。去名录查目录,**归档名 = 软件名** |
|
||||
| `%UserProfile%\Documents\PowerShell` | 字面路径(含 `\` `/` 或 `%` 就按路径处理),归档名沿用 `<末级名>_from_<上级路径>` |
|
||||
| `FooClolor` | **软件名**:去 `SoftwareCatalog.psd1` 查目录,**归档名 = 软件名**。一个软件可以挂多个目录 |
|
||||
| `%UserProfile%\Documents\PowerShell` | **手写目录**:含 `\` `/` 或 `%` 就按路径处理,归档名沿用 `<末级名>_from_<上级路径>` |
|
||||
| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 |
|
||||
|
||||
| 标记 | 作用 |
|
||||
@@ -111,10 +127,60 @@
|
||||
| `pathname` | 用路径命名算法而不是软件名 |
|
||||
| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 |
|
||||
|
||||
排除模式:相对归档根目录。以 `!` 开头表示"任意层级下匹配这个组件名"(7z 的 `-xr!`)。
|
||||
分隔符 `,` 与 `;` 都可以。**不要自己写引号**;模式里的空格会被自动转成 `?`。
|
||||
### 两种写法都支持追加(`:+`)与排除(`:-`)
|
||||
|
||||
几条已经踩过的坑(工具会处理,写的时候知道就行):
|
||||
| 记号 | 作用 |
|
||||
| --- | --- |
|
||||
| `:+` | **追加**一个目录;写成软件名时会按名录展开成它的全部目录。可以写多个、位置随意,追加进来的目录与主目录一起打进同一个归档 |
|
||||
| `:-` | **排除**模式(`::` 是历史别名,等价)。`,` 与 `;` 都当分隔符 |
|
||||
|
||||
```text
|
||||
# 软件名 + 追加 + 排除
|
||||
scoop :- !*Cache :+ D:\scoop-extra
|
||||
|
||||
# 手写目录 + 追加 + 排除
|
||||
C:\Programs\MiFlash :+ MiFlash_Unlock :- MiFlash\logs\
|
||||
```
|
||||
|
||||
> 手写目录的 `:+` 以前会被整段丢掉(只有软件名写法才生效),现在已经修好。
|
||||
|
||||
### 行尾可以写"为什么"
|
||||
|
||||
行尾的 ` # 说明` 会被解析出来,运行时和目录介绍一起打印:
|
||||
|
||||
```text
|
||||
Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
|
||||
```
|
||||
|
||||
`#` 必须前面有空白才算注释,所以路径里的 `C:\a#b` 不受影响。
|
||||
|
||||
### 运行时会把每个条目的目录逐条介绍出来
|
||||
|
||||
目录介绍来自 `SoftwareCatalog.psd1`,追加/排除的**来源**来自清单:
|
||||
|
||||
```text
|
||||
[INFO] 条目:scoop
|
||||
[INFO] 归档:scoop
|
||||
[INFO] 说明:scoop 各应用的持久化数据 + scoop 自身配置
|
||||
[INFO] 目录 1/2:C:\Users\Shuery\scoop\persist
|
||||
[INFO] 来源:软件名录;存在,会打包
|
||||
[INFO] 介绍:scoop 里各应用的持久化数据(重装应用就会丢,必须备份)
|
||||
[INFO] 目录 2/2:C:\Users\Shuery\.config\scoop
|
||||
[INFO] 来源:软件名录;存在,会打包
|
||||
[INFO] 介绍:scoop 自身的配置(源、代理、已安装清单)
|
||||
[INFO] 排除 2 条(来自 BackupConfig.psd1 的 DefaultExcludes):!Thumbs.db、!desktop.ini
|
||||
```
|
||||
|
||||
`Restore.ps1` 也会打印"哪棵子树还原到哪个目录、会新建还是覆盖"。
|
||||
|
||||
### 几个必须知道的约束
|
||||
|
||||
- **同一条目里不能有两个同名目录。** 归档内的顶层名就是目录自己的名字,两个 `persist`
|
||||
在包里会混成一棵树。脚本会在打包前明确报错(退出码 1)并让你拆成两个条目,不会静默混淆。
|
||||
- **多目录条目恢复时只解出各自那棵子树**,不会再出现"把兄弟目录也复制到别的父目录下"。
|
||||
- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖。
|
||||
|
||||
排除模式本身的坑(工具会处理,写的时候知道就行):
|
||||
|
||||
- **模式里不要写引号。** `-x!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。
|
||||
- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。
|
||||
@@ -152,11 +218,17 @@
|
||||
.\tools\Rename-Archives.ps1 -Apply # 确认后执行
|
||||
```
|
||||
|
||||
> **合并条目 = 换归档名。** 例如把 `scoop-config` / `scoop-persist` 合成一个 `scoop`
|
||||
> 数组条目后,归档名从两个变成 `scoop.7z`;旧的 `scoop-config.7z` / `scoop-persist.7z`
|
||||
> 就**没有清单条目指向了**(会出现在孤儿归档审计里)。确认新的 `scoop.7z` 校验通过之后
|
||||
> 再删旧的 —— 重命名工具只改名,不会合并归档内容。
|
||||
|
||||
## 恢复语义
|
||||
|
||||
- 用 `7z x` 解压到目标的**父目录**,覆盖同名文件。
|
||||
- **归档内部布局与历史完全一致**:根目录仍是源目录名(软件名只用于归档文件名)。
|
||||
- 用 `7z x` 把归档里**该目标对应的那棵子树**解到目标的父目录,覆盖同名文件。
|
||||
- **归档内部布局与历史完全一致**:顶层仍是源目录名(软件名只用于归档文件名)。
|
||||
所以恢复逻辑不需要"剥掉一层",现有归档也不会因为重命名而解不开。
|
||||
- **一个条目挂多个目录时,每个目录只还原自己那棵子树**,不会把兄弟目录也复制到别的父目录下。
|
||||
- **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
|
||||
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
|
||||
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
|
||||
@@ -249,7 +321,7 @@ $env:BAKNRET_PASSWORD = '...' # 或
|
||||
|
||||
| 套件 | 命令 | 需要什么 | 覆盖 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 62 项:解析、命名、排除翻译、命令行拼接、manifest / 配置 / 名录,外加**用子进程真正跑 `Backup.ps1` / `Restore.ps1`** 的端到端与回归 |
|
||||
| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 82 项:解析、命名、排除翻译、命令行拼接、manifest / 配置 / 名录、**两种写法 × `:+`/`:-`**,外加**用子进程真正跑 `Backup.ps1` / `Restore.ps1`** 的端到端与回归 |
|
||||
| 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 49 项:同样的单元面,适合没装 Pester 的机器 |
|
||||
| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 23 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 |
|
||||
| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 把 `Backups/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) |
|
||||
@@ -293,6 +365,11 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
|
||||
| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) |
|
||||
| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报 |
|
||||
| `Resolve-BackupEntry` 里的 `$rootName` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 |
|
||||
| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录展开 / 字面路径) |
|
||||
| 软件名录的多目录写法 | 只有 `@{ Dirs = @(...) }`,没有"这个目录是干什么的" | 支持**对象数组**(`Path` + `Description`),运行时逐条介绍 |
|
||||
| 多目录条目的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个源只解出**它自己那棵子树** |
|
||||
| 同一条目里两个同名目录 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示拆成两个条目 |
|
||||
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由 |
|
||||
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
|
||||
|
||||
## 设计取舍(有意为之,不是遗漏)
|
||||
@@ -310,6 +387,6 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
|
||||
- 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
|
||||
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
|
||||
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
|
||||
- `Variants`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。
|
||||
- `Variants`(同名目录分散在多处)当前打包第一个位置;恢复时每个源只解出**它自己那棵子树**,不会把兄弟目录复制到别的父目录下。
|
||||
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
|
||||
- **磁盘空间守卫是逐条目判断的**,不预留"本次运行后续条目"的空间。`MinFreeSpaceGB` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。
|
||||
Reference in new issue
Block a user