备份前空间预估;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
}