备份前空间预估;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 字段一致性、空间预估输出。
This commit is contained in:
Shuery committed 2026-09-22 08:26:09 +08:00
1 parent 43fa4e52dd
commit 7173e8ae10
5 files changed
+244 -4

No files matched your search

+117
View File
@@ -396,6 +396,117 @@ function Invoke-BackupItem {
}
}
# ============================================================================
# 备份前空间预估(只读,不写任何东西)
# ============================================================================
# 只做一件事:动手之前告诉用户"这次大概要写多少、盘够不够"。
# 不做更复杂的占用控制 —— 真正拦住某个条目的是主循环里的逐条目守卫。
#
# 模型(按清单顺序模拟一遍):
# * 每个要重打的条目会先写一份**临时**归档,这时旧归档还在,所以那一刻占用的
# 是"当前累计净增量 + 本次预估";
# * 原子替换之后,本次净增量 = 预估 - 现有归档大小(换成更小的归档会把空间还回来)。
# 于是:峰值新增 = max_i( 第 i 项之前的累计净增量 + 第 i 项的预估大小 )。
$spacePlan = @()
$spaceSkipped = 0
$spaceNoSource = 0
foreach ($planLine in $lines) {
$planItem = ConvertFrom-BackupListLine -Line $planLine
if (-not $planItem) { continue }
$planDisplayPath = $planItem.Path
$planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $planResolved.BaseName) { continue }
if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue }
if ($planResolved.Blocking) { continue }
$planSources = @($planResolved.Sources | Where-Object { Test-Path -LiteralPath $_.SourcePath })
if ($planSources.Count -eq 0) { $spaceNoSource++; continue }
$planSourceBytes = [int64]0
$planSourceFiles = 0
$planLatest = $null
foreach ($planSource in $planSources) {
$planSummary = Get-FolderSummary -FolderPath $planSource.SourcePath
$planSourceBytes += [int64]$planSummary.TotalSize
$planSourceFiles += [int]$planSummary.FileCount
if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) {
$planLatest = $planSummary.LatestModifiedTime
}
}
$planArchiveName = $planResolved.BaseName + $tool.Extension
$planArchivePath = Join-Path $BackupDir $planArchiveName
$planExistingItem = if (Test-Path -LiteralPath $planArchivePath) { Get-Item -LiteralPath $planArchivePath } else { $null }
$planExistingBytes = if ($planExistingItem) { [int64]$planExistingItem.Length } else { [int64]0 }
# 与主循环同一套判断:源没更新就不会重打
if (-not $Force -and $planExistingItem -and $planLatest -and $planLatest -le $planExistingItem.LastWriteTime) {
$spaceSkipped++
continue
}
$planEstimate = if ($planExistingBytes -gt 0) {
[int64][math]::Min([double]$planSourceBytes, [double]$planExistingBytes * 1.3)
} else {
# 没有历史归档可比时按"完全不压缩"的悲观值估,宁可报多不报少
$planSourceBytes
}
$spacePlan += [pscustomobject]@{
Name = $planResolved.BaseName
Source = $planDisplayPath
Files = $planSourceFiles
SourceBytes = $planSourceBytes
Existing = $planExistingBytes
Estimate = $planEstimate
}
}
$freeNowGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($spacePlan.Count -eq 0) {
Write-Log '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO
} else {
$spacePeak = [double]0
$spaceCumulative = [double]0
foreach ($plan in $spacePlan) {
$spacePeak = [math]::Max($spacePeak, $spaceCumulative + $plan.Estimate)
$spaceCumulative += ($plan.Estimate - $plan.Existing)
}
$peakGB = $spacePeak / 1GB
$netGB = $spaceCumulative / 1GB
$estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB
$existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB
Write-Log '==== 备份前空间预估(只读)====' -Level INFO
Write-Log (" 目标卷可用空间:{0} GB" -f $freeNowGB)
Write-Log (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource)
Write-Log (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2))
foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) {
Write-Log (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f `
$plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB))
}
if ($spacePlan.Count -gt 15) {
Write-Log (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15))
}
Write-Log (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2))
if ($freeNowGB -lt 0) {
Write-Log ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN
} elseif ($peakGB -le $freeNowGB) {
Write-Log (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO
} else {
Write-Log (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f `
[math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN
Write-Log ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN
}
Write-Log '============================' -Level INFO
}
# ============================================================================
# 主流程
# ============================================================================
@@ -662,6 +773,12 @@ foreach ($line in $lines) {
if ($DryRun) {
Write-Log '试运行:manifest 与归档都不会被写入' -Level INFO
} else {
# manifest 里写了 archive 的记录,磁盘上就必须真有那个文件
$clearedArchiveFields = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir
if ($clearedArchiveFields.Count -gt 0) {
Write-Log ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN
}
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
}
+42 -1
View File
@@ -1310,6 +1310,47 @@ function Read-BaknretManifest {
}
}
function Sync-BaknretManifestArchive {
<#
.SYNOPSIS
清空 manifest 里"指向了一个不存在的归档"的 archive 字段,返回被清空的条目名。
.DESCRIPTION
维持一条不变式:**manifest 里写了 archive 的记录,磁盘上就一定有那个文件。**
没有这条不变式时会出现两种误导:
* 源不存在的条目(missing-source / invalid-path)本来就没有归档,记录里却留着
一个不存在的文件名,Restore 每次都会打一条
"manifest 记录的归档不存在,回退按文件名查找",看着像出了问题其实没有;
* 人工删掉了某个归档(例如把它并进了另一个条目)之后,记录还宣称它在那儿。
只清 archive 字段,保留条目本身的历史(source / 成功次数 / 上次恢复时间),
因为"这个软件曾经备份过、现在源不在了"本身就是有用信息。
#>
param(
[Parameter(Mandatory = $true)]$Manifest,
[Parameter(Mandatory = $true)][string]$BackupDir
)
$cleared = @()
if (-not $Manifest -or -not $Manifest.items) { return , $cleared }
foreach ($key in @($Manifest.items.Keys)) {
$item = $Manifest.items[$key]
if (-not $item) { continue }
if (-not ($item.PSObject.Properties.Name -contains 'archive')) { continue }
$archive = $item.archive
if ([string]::IsNullOrWhiteSpace([string]$archive)) { continue }
if (Test-Path -LiteralPath (Join-Path $BackupDir $archive)) { continue }
$item.archive = $null
$cleared += $key
}
return , $cleared
}
function Write-BaknretManifest {
<#
.SYNOPSIS
@@ -1488,6 +1529,6 @@ Export-ModuleMember -Function @(
'Get-ArchiveTopLevelNames',
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
'Get-FolderSummary',
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
'Read-BaknretManifest', 'Write-BaknretManifest', 'Sync-BaknretManifestArchive', 'Move-BaknretArchiveIntoPlace',
'Get-BaknretConfig', 'Get-BaknretPassword'
)
+32 -3
View File
@@ -10,6 +10,7 @@
- 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。
- 备份结束做**孤儿归档审计**:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。
- 恢复支持 `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` / `-Skip`;其中三种"只看不写"的模式(`-WhatIf` / `-DryRun` / `-VerifyOnly`)**一个字节都不写**。
- 动手之前先**预估本次所需空间**并直接判断目标卷够不够(不够只告警、不中断)。
---
@@ -256,6 +257,31 @@ Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
`Restore.ps1` **优先用 manifest 定位归档**,查不到才退回"从文件名反推路径"。
如果 `BackupList.txt` 丢了,`Restore.ps1` 会优先用 manifest 里的 `source` 自动重建。
## 备份前空间预估
每次备份在**动手之前**先按清单顺序模拟一遍,把"这次要写多少、盘够不够"直接打出来:
```text
[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`,与控制台内容一致。
@@ -321,7 +347,7 @@ $env:BAKNRET_PASSWORD = '...' # 或
| 套件 | 命令 | 需要什么 | 覆盖 |
| --- | --- | --- | --- |
| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 82 项:解析、命名、排除翻译、命令行拼接、manifest / 配置 / 名录、**两种写法 × `:+`/`:-`**,外加**用子进程真正跑 `Backup.ps1` / `Restore.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/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) |
@@ -369,7 +395,8 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
| 软件名录的多目录写法 | 只有 `@{ Dirs = @(...) }`,没有"这个目录是干什么的" | 支持**对象数组**(`Path` + `Description`),运行时逐条介绍 |
| 多目录条目的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个源只解出**它自己那棵子树** |
| 同一条目里两个同名目录 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示拆成两个条目 |
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由 |
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 |
| manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 |
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
## 设计取舍(有意为之,不是遗漏)
@@ -389,4 +416,6 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
- `Variants`(同名目录分散在多处)当前打包第一个位置;恢复时每个源只解出**它自己那棵子树**,不会把兄弟目录复制到别的父目录下。
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
- **磁盘空间守卫是逐条目判断的**,不预留"本次运行后续条目"的空间。`MinFreeSpaceGB` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。
- **空间只做"预估 + 提示",不做全局拦截。** 备份前会打印预计峰值新增和"够不够"的结论;
不够时**只告警不中断**,真正放不下的条目交给逐条目守卫跳过。`MinFreeSpaceGB` 是告警阈值。
想稳妥跑完就先腾空间,或用 `-Only` / `-Skip` 分批。
+2
View File
@@ -498,6 +498,8 @@ if (-not $VerifyOnly -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
try {
if ($manifestDirty) {
# 顺手维持"manifest 写了 archive,磁盘上就真有那个文件"这条不变式
$null = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-Log 'manifest 已更新(记下本次恢复时间)' -Level DEBUG
} else {
+51
View File
@@ -280,6 +280,12 @@ Describe '集成:手写路径 + :+ 追加 的打包与恢复' -Skip:(-not ($sc
}
}
It '备份前会打印空间预估与"够不够"的结论' {
$script:AppendBackupRun.Output | Should -Match '备份前空间预估'
$script:AppendBackupRun.Output | Should -Match '要重打'
$script:AppendBackupRun.Output | Should -Match '结论:'
}
It '备份成功,manifest.roots 记录两棵子树' {
$script:AppendBackupRun.ExitCode | Should -Be 0
$archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0]
@@ -414,3 +420,48 @@ Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip
$script:OrphanRun.Output | Should -Match 'manifest 里还留着它的历史记录'
}
}
# ============================================================================
Describe 'manifest 一致性:archive 字段只在文件真的存在时才写' {
# ============================================================================
BeforeAll {
$script:SyncRoot = Join-Path $script:Sandbox 'sync'
New-Item -ItemType Directory -Path $script:SyncRoot -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:SyncRoot 'real.7z') 'x'
$script:SyncManifestPath = Join-Path $script:SyncRoot 'manifest.json'
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$manifest.items['real'] = [pscustomobject]@{ archive = 'real.7z'; action = 'backed-up'; source = 'real' }
$manifest.items['gone'] = [pscustomobject]@{ archive = 'gone.7z'; action = 'missing-source'; source = 'gone' }
$manifest.items['none'] = [pscustomobject]@{ archive = $null; action = 'missing-source'; source = 'none' }
Write-BaknretManifest -Path $script:SyncManifestPath -Manifest $manifest | Out-Null
}
It '存在的归档保留 archive,不存在的被清空' {
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared.Count | Should -Be 1
$cleared[0] | Should -Be 'gone'
$manifest.items['real'].archive | Should -Be 'real.7z'
$manifest.items['gone'].archive | Should -BeNullOrEmpty
$manifest.items['none'].archive | Should -BeNullOrEmpty
}
It '清空 archive 时保留条目本身的历史(source / action 不动)' {
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$null = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$manifest.items['gone'].source | Should -Be 'gone'
$manifest.items['gone'].action | Should -Be 'missing-source'
}
It '人工删掉归档之后再同步一次,记录会被纠正过来' {
Remove-Item -LiteralPath (Join-Path $script:SyncRoot 'real.7z') -Force
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath
$cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared | Should -Contain 'real'
$manifest.items['real'].archive | Should -BeNullOrEmpty
}
}