Shuery 7173e8ae10 备份前空间预估;manifest 的 archive 字段只在文件真的存在时才写
需求
- 写满盘这件事只做一件事:**备份前预测本次所需大小,并提示用户存储够不够**,
  不再往"更复杂的占用控制"方向做。

实现
- Backup.ps1 新增只读的"备份前空间预估",在动手之前按清单顺序模拟一遍:
  * 逐个条目枚举源目录得到真实源大小/文件数,并读取现有归档大小;
  * 沿用主循环那套"源未更新就跳过"的判断,所以列出来的就是**本次真的会重打**的条目;
  * 估算模型:临时归档写完时旧归档还在,那一刻占用 = 当前累计净增量 + 本次预估;
    原子替换后净增量 = 预估 − 旧归档大小;峰值取整个过程的最大值;
  * 预估归档大小:有历史归档取 min(源大小, 旧归档 × 1.3),没有则按"不压缩"的悲观值;
  * 打印:可用空间、要重打的条目数(及跳过/缺源的数量)、逐条目明细(前 15 条)、
    预计峰值新增与净增量,最后给一句结论——"空间足够"或"空间可能不够!…差 Z GB"。
  * 不够时**只告警、不中断**:真正放不下的条目仍由逐条目守卫跳过。
- 新增 Sync-BaknretManifestArchive(Common.psm1,Backup/Restore 都在写 manifest 前调用):
  维持不变式"manifest 里写了 archive 的记录,磁盘上就一定有那个文件",
  把指向不存在归档的 archive 字段清空,但保留 source / action / 历史计数。
  这样"源不存在的条目"不会再让 Restore 反复报"manifest 记录的归档不存在",
  人工删掉归档(例如把它并进别的条目)之后记录也会自我纠正。

整理(本机备份集)
- 按用户确认,删除了已被 scoop.7z 覆盖的 scoop-config.7z 与 scoop-persist.7z
  (删除前先 7z t 复验 scoop.7z:233 MB / 29478 项 / 顶层 [persist, scoop]),
  并清掉 manifest 里这两条历史记录;24 条记录的 archive 现在全部存在于磁盘上。

测试
- Pester 88 项、零依赖单元 49 项、端到端 23 项,全部通过。
  新增覆盖:对象数组名录、两种写法 × :+/-、同名目录拒绝执行、行尾 # 说明、
  孤儿审计(含"manifest 里还有历史记录"的说明)、archive 字段一致性、空间预估输出。
2026-09-22 08:26:09 +08:00

BakNRet

把 BackupList.txt 里列出的软件 / 目录用 7-Zip 打包进 Backups/,并且能用 Restore.ps1 原样恢复的 Windows 备份工具。

  • 清单里直接写软件名即可(如 FooClolor),目录映射维护在 SoftwareCatalog.psd1 里。
  • 归档名就是软件名(FooClolor.7z),不再是 FooClolor_from_C_+Programs.7z。
  • 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,运行备份/恢复不需要任何模块(只有跑 Pester 测试才需要 Pester 5)。
  • 每个归档写完后做 7z t 内容校验,先写临时文件、校验通过再原子替换。
  • 每次运行产出可核对的 Backups/manifest.json 与 logs/*.log。
  • 退出码可靠:有失败就返回 1,计划任务能正确判断成败。
  • 备份结束做孤儿归档审计:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。
  • 恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip;其中三种"只看不写"的模式(-WhatIf / -DryRun / -VerifyOnly)一个字节都不写。
  • 动手之前先预估本次所需空间并直接判断目标卷够不够(不够只告警、不中断)。

快速开始

# 1. 先试运行:只打印计划,不写任何文件
.\Backup.ps1 -DryRun

# 2. 正式备份
.\Backup.ps1

# 3. 强制重打(忽略"源未更新"判断)
.\Backup.ps1 -Force

# 3b. 确认可以接受"有文件被占用而没打进归档"时,允许覆盖完整归档
.\Backup.ps1 -Force -AcceptWarnings

# 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名)
.\Backup.ps1  -Only 'FooClolor','.ssh'
.\Restore.ps1 -Only 'Edge' -Force

# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
.\Restore.ps1 -DryRun

# 6. 只校验所有归档完整性,不解压(只读,安全)
.\Restore.ps1 -VerifyOnly

文件说明

路径 作用
SoftwareCatalog.psd1 软件名 → 目录的映射,清单里写软件名的依据
BackupList.txt 备份 / 恢复共用的清单,唯一的"要备份什么"来源
BackupConfig.psd1 目录、空间阈值、校验、加密等配置
Backup.ps1 / Restore.ps1 备份 / 恢复入口
Common.psm1 公共模块(日志、外部命令、解析、名录、manifest)
Backups/ 归档与 manifest.json(已 gitignore)
logs/ 每次运行的日志(已 gitignore)
tests/ 测试:Pester 套件(*.Tests.ps1)、零依赖套件、端到端验收、真实归档恢复演练
tools/Register-BackupTask.ps1 注册 / 移除计划任务
tools/Rename-Archives.ps1 把按路径命名的旧归档重命名成软件名(默认试运行)
tools/Install-TestDependencies.ps1 把 Pester 5 装到仓库内的 .tools/(不动机器上的全局模块)

SoftwareCatalog.psd1 —— 软件名 → 目录

@{
    # 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。这是最容易踩的一个坑。
  • 数组元素也接受纯字符串(scoop = @('D:\a', 'D:\b')),以及旧的 @{ Dirs = @(...) } / @{ Variants = @(...) } 写法 —— 三种都能用。
  • 目录当前不存在也不会被丢掉:备份时跳过并记 missing-source,但恢复时仍然知道 "这块内容原本该回到哪个位置",这正是恢复要用的。
  • 前缀补全:写 D:\Programs\legendary,实际目录是 legendary_2.0.4 时会自动匹配。 只认 <名>_* 与 <名>-*,不会把 Legendary 误配成 LegendarySomething。
  • 一个软件里不能有两个同名目录(例如两个 persist):归档内的顶层名就是目录名, 那样会在包里混成一棵树。脚本会明确报错(退出码 1)让你拆成两个条目。

分文件维护:用 Includes 引入其它名录文件(路径相对本文件):

@{
    Includes = @('SoftwareCatalog.games.psd1')
    ...
}

BackupList.txt 语法

<软件名 或 手写目录> [ :+ <再追加一个目录/软件名> ... ] [ :- <排除模式>[,<排除模式>...] ] [ @<标记> ]

两种写法(混用没问题)

写法 说明
FooClolor 软件名:去 SoftwareCatalog.psd1 查目录,归档名 = 软件名。一个软件可以挂多个目录
%UserProfile%\Documents\PowerShell 手写目录:含 \ / 或 % 就按路径处理,归档名沿用 <末级名>_from_<上级路径>
FooClolor @pathname 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目
标记 作用
encrypt 用 7z 加密该归档,见下文「加密」
pathname 用路径命名算法而不是软件名
root=<名> 尚未实现:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效

两种写法都支持追加(:+)与排除(:-)

记号 作用
:+ 追加一个目录;写成软件名时会按名录展开成它的全部目录。可以写多个、位置随意,追加进来的目录与主目录一起打进同一个归档
:- 排除模式(:: 是历史别名,等价)。, 与 ; 都当分隔符
# 软件名 + 追加 + 排除
scoop :- !*Cache :+ D:\scoop-extra

# 手写目录 + 追加 + 排除
C:\Programs\MiFlash :+ MiFlash_Unlock :- MiFlash\logs\

手写目录的 :+ 以前会被整段丢掉(只有软件名写法才生效),现在已经修好。

行尾可以写"为什么"

行尾的 # 说明 会被解析出来,运行时和目录介绍一起打印:

Edge :- !*Cache,!Crashpad  # 缓存与崩溃转储都可再生,不进归档

# 必须前面有空白才算注释,所以路径里的 C:\a#b 不受影响。

运行时会把每个条目的目录逐条介绍出来

目录介绍来自 SoftwareCatalog.psd1,追加/排除的来源来自清单:

[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 才可以。
  • 以第一个 :: 为界切分。 : 在 Windows 路径里只可能是盘符,:: 不会出现在真实路径里,所以整行被一对引号包住的历史写法也能正确解析。
  • 归档名重复会直接报错。 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖 —— 脚本拒绝执行并提示。
  • 不带 ! 的普通模式是"相对归档根目录"锚定的(展开成 -x!<归档内完整路径>),所以只排除根目录下那一份。 Edge 的 OneAuth\WebView2\EBWebView\ 里还藏着一整套自己的 Crashpad / BrowserMetrics / ProvenanceData / optimization_guide,根锚定模式碰不到它们 —— 这类可再生的东西要用 !<组件名>(展开成 -xr!)才会在任意层级命中。
  • ! 是按"路径组件"精确匹配,不是子串。 !Crashpad 不会误伤 CrashpadMetrics.pma 或 ProvenanceDataTensors,也不会漏掉嵌套的 ...\EBWebView\Crashpad\。

实测效果(本机真实 Edge 配置,源 4619.9 MB):

Edge 归档 大小 条目数
排除规则生效前 1781 MB 27961
排除规则生效后 72 MB 2294

书签、密码(Login Data)、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留; 缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。

归档命名与迁移

条目类型 归档名
软件名 <软件名>.7z
字面路径 <末级名>_from_<上级路径用 + 连接>.7z
软件名 + @pathname 同字面路径

从旧版本升级时用重命名工具把存量归档搬过来(默认试运行、逐份大小校验、重建 manifest):

.\tools\Rename-Archives.ps1            # 先看计划
.\tools\Rename-Archives.ps1 -Apply     # 确认后执行

合并条目 = 换归档名。 例如把 scoop-config / scoop-persist 合成一个 scoop 数组条目后,归档名从两个变成 scoop.7z;旧的 scoop-config.7z / scoop-persist.7z 就没有清单条目指向了(会出现在孤儿归档审计里)。确认新的 scoop.7z 校验通过之后 再删旧的 —— 重命名工具只改名,不会合并归档内容。

恢复语义

  • 用 7z x 把归档里该目标对应的那棵子树解到目标的父目录,覆盖同名文件。
  • 归档内部布局与历史完全一致:顶层仍是源目录名(软件名只用于归档文件名)。 所以恢复逻辑不需要"剥掉一层",现有归档也不会因为重命名而解不开。
  • 一个条目挂多个目录时,每个目录只还原自己那棵子树,不会把兄弟目录也复制到别的父目录下。
  • 不做镜像同步:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
  • 目标目录比归档新时默认跳过,需要覆盖就加 -Force。
  • -WhatIf / -DryRun 只打印计划;-VerifyOnly 只跑 7z t。 这三种模式一个字节都不写(manifest.json 也不会被碰)。
  • 排除规则只在下一份归档里生效:已经生成的归档不会因为改了排除表而"变干净"。

manifest.json

以归档基础名为键记录每个条目:

字段 含义
source 清单里的原始写法(软件名或路径)
resolvedSource 展开后的路径
roots 归档内真实的顶层条目名(就是源目录 / 源文件名;只统计真实存在的源)。每次重新处理该条目时刷新
catalog 名录里记录的路径(便于追溯软件名到底指向哪)
archive 归档文件名
action backed-up / skip-unchanged / missing-source / invalid-path / failed / planned
reason 跳过或失败的原因
exitCode / verified / warnings 压缩工具退出码、是否通过 7z t、当前在位归档是否有警告
attemptWarnings 本次尝试是否报了警告(与 warnings 区分:保留旧归档时前者为 true、后者仍为 false)
sourceFiles / sourceBytes / archiveBytes 源文件数、源大小、归档大小
lastSuccessAt / successCount / failCount / lastRestoreAt / encrypted 历史与安全标记

Restore.ps1 优先用 manifest 定位归档,查不到才退回"从文件名反推路径"。 如果 BackupList.txt 丢了,Restore.ps1 会优先用 manifest 里的 source 自动重建。

备份前空间预估

每次备份在动手之前先按清单顺序模拟一遍,把"这次要写多少、盘够不够"直接打出来:

[INFO] ==== 备份前空间预估(只读)====
[INFO]   目标卷可用空间:7.37 GB
[INFO]   本次要重打 8 个条目(另有 7 个源未更新会跳过、9 个源不存在)
[INFO]   新归档合计约 2.44 GB;其中会替换掉的旧归档 1.89 GB
[INFO]     - Edge                   源  2,303.8 MB /  11354 文件   现有 1,781.3 MB   预估 2,303.8 MB
[INFO]     - MiFlash_Unlock         源    231.3 MB /    153 文件   现有    70.0 MB   预估    91.0 MB
[INFO]   ...
[INFO]   预计峰值新增占用:2.25 GB(全程净增量 0.55 GB)
[INFO]   结论:空间足够(预计用 2.25 GB / 可用 7.37 GB)
[INFO] ============================
  • 估算模型:临时归档写完时旧归档还在,那一刻占用"当前累计净增量 + 本次预估", 原子替换之后本次净增量 = 预估 − 旧归档大小。峰值取整个过程的最大值。
  • 预估归档大小:有历史归档时取 min(源大小, 旧归档 × 1.3);没有历史归档时按 "完全不压缩"的悲观值估 —— 宁可报多不报少。
  • 结论只有两种:空间足够,或者"空间可能不够!预计需要 X GB,可用 Y GB,差 Z GB"。 不够时只告警、不中断 —— 真正放不下的条目会被逐条目守卫跳过;想稳妥就腾空间或加 -Only / -Skip 分批。
  • 这一步是只读的,不改任何文件;-DryRun 也照跑。

日志

logs/backup-<时间戳>.log / logs/restore-<时间戳>.log,与控制台内容一致。 压缩工具自身的实时输出直接进控制台,不进日志(见「设计取舍」)。

配置(BackupConfig.psd1)

@{
    BackupDir        = 'Backups'      # 相对路径按脚本所在目录解析
    LogDir           = 'logs'
    SnapshotDir      = 'Backups\snapshots'
    SoftwareCatalog  = 'SoftwareCatalog.psd1'
    CatalogMaxDepth  = 5              # 前缀补全时最多向下找几层
    MinFreeSpaceGB   = 5
    VerifyArchive    = $true          # 归档后跑 7z t
    ComputeHash      = $false         # 是否额外算 SHA256(大归档很慢)
    CompressionLevel = 9
    ToolOutput       = 'live'         # live | quiet
    Snapshot         = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
    Encryption       = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true }
    DefaultExcludes  = @('!Thumbs.db', '!desktop.ini')
}

优先级:命令行参数 > BackupConfig.psd1 > 代码内置默认值,也可以用 -ConfigPath 指定其它配置文件。

加密

默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。

# 方式一:只为个别条目加密(.ssh 里是私钥,最典型)
#   在 BackupList.txt 里写成:
#   .ssh @encrypt

# 方式二:全部加密,改配置
#   Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }

# 口令来源(二者取其一)
$env:BAKNRET_PASSWORD = '...'      # 或
.\Backup.ps1 -KeyFile 'D:\secret\baknret.key'   # 文件首行即口令

要求加密但取不到口令时,该条目会明确失败,绝不会退化成明文归档。 恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。

⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。

计划任务

.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun   # 先看将要注册什么
.\tools\Register-BackupTask.ps1 -At '21:30'            # 注册
.\tools\Register-BackupTask.ps1 -Remove                # 移除

任务调用 Backup.ps1,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里可读。

测试

四套,按"需要多少依赖"分层:

套件 命令 需要什么 覆盖
Pester 套件(推荐) .\tests\Run-Pester.ps1 Pester 5.0+ 与 7z 88 项:解析、命名、排除翻译、命令行拼接、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/ 里真实的那批归档解到临时目录,再和活源逐字节对拍(全程不碰真实目录)

演练会把"源在备份之后变过"和"归档/解压有问题"分开:内容不一致时看活源文件的修改时间, 晚于归档时间就算"源变了"(只提示),不晚于归档时间却内容不同才算失败。真实机器上的归档 常常是几周前的,不这样区分就天天报假失败。

Pester 套件要求 5.0+。系统自带的是 3.4.0,没有 Should -Be,套件会直接语法错误, 所以 Run-Pester.ps1 会先查版本,查不到就以退出码 2 结束并打印安装命令。两种装法:

.\tools\Install-TestDependencies.ps1                  # 只装进仓库内的 .tools/(推荐,不动机器上的全局模块)
# 或者
Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0

Run-Pester.ps1 优先使用 .tools/ 里的本地副本,其次是机器上已装的 5.x; .tools/ 已进 .gitignore。

Pester 套件里的端到端用例是用子进程跑 Backup.ps1 / Restore.ps1 的,原因有二: 两个脚本结尾都会 exit,同进程 & 调用会把 Pester 宿主一起带走;而且子进程给出的是 真正的进程退出码,正好独立验证"退出码取法"这条修复。

相对旧版修了什么

问题 旧行为 现行为
Start-Process -PassThru 的 ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null 压缩明明成功却报"压缩失败",exit 2 → 删档重试 的自愈分支永远不可达 用 .NET Process 继承控制台启动,退出码可靠
排除模式写成 -x!"路径" 引号成为模式的一部分,排除对所有条目都失效 不再嵌引号;含空格自动转 ?,! 前缀走 -xr!
解析器用 ; 分隔,清单里写的是 , 整串被当成一个模式,等于没有排除 , 与 ; 都支持
^"([^"]+)" 贪婪匹配 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 先按 :: 切分再处理引号
归档名由路径拼出 加一条备份要自己算名字,名字随路径变动 清单写软件名,归档名就是软件名
直接更新已有归档(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 时不再误报
Resolve-BackupEntry 里的 $rootName 在赋值之前就被引用,会读到外层作用域残留的值 提前赋值,回归测试钉死
手写目录的 :+ 追加 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 两种写法都生效,追加项还会标出来源(名录展开 / 字面路径)
软件名录的多目录写法 只有 @{ Dirs = @(...) },没有"这个目录是干什么的" 支持对象数组(Path + Description),运行时逐条介绍
多目录条目的恢复 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 每个源只解出它自己那棵子树
同一条目里两个同名目录 静默混成一棵树,两边的数据都错 打包前明确报错(退出码 1)并提示拆成两个条目
运行时的可解释性 只有一行"开始备份: X" 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够
manifest 的 archive 字段 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" 只在文件真的存在时才写;删掉归档后同步一次就自我纠正
没有名录、manifest、测试、README,不是 git 仓库 — 都有

设计取舍(有意为之,不是遗漏)

  • 放弃 7z 的更新模式(u)。 7z 默认固实压缩,u 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。
  • 归档内部不套一层软件名目录。 考虑过用暂存目录(硬链/复制)把归档根目录改成软件名,代价是多一次链接开销、实现复杂度上升,收益只是"解开包第一层好看"。归档名已经是软件名,包内保持源目录名也便于确认内容来源。顺带一提,7z 的 -spf 不是干这个的(它是 use fully qualified file paths)。
  • 不捕获压缩工具的输出。 结构化记录交给日志与 manifest.json;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败。
  • 有警告(退出码 1)时不覆盖完整的归档。 被占用的文件会让 7z 返回 1,此时新归档是不完整的。实测 Edge 运行时打包,118 个文件读不到,其中包含 Login Data(密码)、Cookies、History、Web Data。所以在位归档完整时脚本保留它、报失败、退出码 1,确认可以接受再显式加 -AcceptWarnings。
  • 名录里的路径不存在时,恢复仍然可用。 源被删掉正是要恢复的场景,所以解析器照旧给出 Sources,备份端则据此跳过。
  • 源路径不存在只算"跳过",不算失败。 会以 missing-source 记进 manifest。失败只统计真正打不开的条目。

已知限制

  • 改软件名等于换归档名。 改名后旧归档不会被自动迁移,用 tools/Rename-Archives.ps1 或手动改名,并注意 manifest 里会留下旧键。
  • 路径里本来就含 + 或 _from_ 时,仅靠文件名无法可靠反推路径,此时依赖 manifest.json。
  • -Snapshot 目前是"复制一份带时间戳的副本",不做自动轮转清理(KeepCount / KeepDays 尚未实现)。
  • 加密归档的常规备份/恢复不依赖 RAR;RAR 与内置 ZIP 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
  • Variants(同名目录分散在多处)当前打包第一个位置;恢复时每个源只解出它自己那棵子树,不会把兄弟目录复制到别的父目录下。
  • root=<名> 标记尚未实现。 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
  • 空间只做"预估 + 提示",不做全局拦截。 备份前会打印预计峰值新增和"够不够"的结论; 不够时只告警不中断,真正放不下的条目交给逐条目守卫跳过。MinFreeSpaceGB 是告警阈值。 想稳妥跑完就先腾空间,或用 -Only / -Skip 分批。
S
Description
No description provided
Readme Apache-2.0
1.6 MiB
0 Stars 1 Watchers 0 Forks
Languages
PowerShell 100%