From 43fa4e52dd86b6fc9b80a7358e348f5334675b5f Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Tue, 22 Sep 2026 08:18:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=A0=BC=E5=BC=8F=EF=BC=9A?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E5=86=99=E6=B3=95=EF=BC=88=E8=BD=AF=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=20/=20=E6=89=8B=E5=86=99=E7=9B=AE=E5=BD=95=EF=BC=89?= =?UTF-8?q?=E9=83=BD=E6=94=AF=E6=8C=81=20:+=20=E8=BF=BD=E5=8A=A0=E4=B8=8E?= =?UTF-8?q?=20:-=20=E6=8E=92=E9=99=A4=EF=BC=9B=E5=90=8D=E5=BD=95=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=AF=B9=E8=B1=A1=E6=95=B0=E7=BB=84=E5=B9=B6=E9=80=90?= =?UTF-8?q?=E6=9D=A1=E4=BB=8B=E7=BB=8D=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求 - 支持两种条目写法: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 无误后可以自行删除。 --- Backup.ps1 | 43 ++- BackupList.txt | 45 ++- Common.psm1 | 517 +++++++++++++++++++------------- README.md | 137 +++++++-- Restore.ps1 | 47 ++- SoftwareCatalog.psd1 | 123 ++++++-- tests/BakNRet.Formats.Tests.ps1 | 416 +++++++++++++++++++++++++ tests/Restore-Drill.ps1 | 86 ++++-- tests/Run-Pester.ps1 | 3 +- tools/Rename-Archives.ps1 | 5 + 10 files changed, 1083 insertions(+), 339 deletions(-) create mode 100644 tests/BakNRet.Formats.Tests.ps1 diff --git a/Backup.ps1 b/Backup.ps1 index a8f8abc..271b8f5 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -444,6 +444,19 @@ foreach ($line in $lines) { } $seenBaseNames[$baseName] = $displayPath + # 归档内顶层同名冲突:明确失败,绝不把两个目录静默搅进同一棵树 + if ($resolved.Blocking) { + Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR + Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null + $failed++; $failures += $displayPath + continue + } + + # 动手之前先把"这条会打包哪些目录、排除了什么、为什么"讲清楚 + Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath ` + -ListExcludes @($item.ExcludePatterns) -ConfigExcludes @($script:Config.DefaultExcludes) ` + -Comment $item.Comment + # Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。 # 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值, # 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的 @@ -459,14 +472,13 @@ foreach ($line in $lines) { # 源存在性检查必须在 Get-FolderSummary / Get-Item 之前: # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 # 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。 + # 源路径存在性以 SourcePath 为准:RelativePaths 是"归档里的名字", + # 目前两者一致,但 SourcePath 才是磁盘上的真实位置。 $expectedRoots = 0 $missingRoots = @() foreach ($source in $resolved.Sources) { - foreach ($relative in $source.RelativePaths) { - $expectedRoots++ - $candidate = "$($source.ParentDir.TrimEnd('\'))\$relative" - if (-not (Test-Path -LiteralPath $candidate)) { $missingRoots += $candidate } - } + $expectedRoots++ + if (-not (Test-Path -LiteralPath $source.SourcePath)) { $missingRoots += $source.SourcePath } } if ($missingRoots.Count -ge $expectedRoots) { @@ -483,13 +495,14 @@ foreach ($line in $lines) { # 归档里只放真实存在的源 $liveSources = @() foreach ($source in $resolved.Sources) { - $live = @($source.RelativePaths | Where-Object { Test-Path -LiteralPath "$($source.ParentDir.TrimEnd('\'))\$_" }) - if ($live.Count -gt 0) { + if (Test-Path -LiteralPath $source.SourcePath) { $liveSources += [pscustomobject]@{ RootName = $source.RootName ParentDir = $source.ParentDir - RelativePaths = $live + RelativePaths = @($source.RelativePaths) SourcePath = $source.SourcePath + Description = $source.Description + Origin = $source.Origin } } } @@ -653,23 +666,27 @@ if ($DryRun) { Write-Log "manifest 已更新:$manifestPath" -Level DEBUG } -# 孤儿归档审计:磁盘上有、但清单里任何条目都不指向的归档。 -# Restore.ps1 只能按条目名找归档,所以孤儿是**恢复不到**的 —— 必须显式点名, +# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。 +# Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名, # 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。 +# +# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目, +# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住, +# 审计就永远不会报——那正是最需要报出来的情况。 # 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里, # 那种情况下报出来的全是假孤儿。 if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) { $known = @{} foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true } - foreach ($key in $manifest.items.Keys) { $known[$key] = $true } $orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) }) if ($orphanArchives.Count -gt 0) { - Write-Log ("发现 {0} 个孤儿归档(没有任何清单条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN + Write-Log ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN foreach ($orphan in $orphanArchives) { - Write-Log (" - {0}({1:N1} MB,{2})" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime) -Level WARN + $inManifest = $manifest.items.Contains($orphan.BaseName) + Write-Log (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN } } else { Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG diff --git a/BackupList.txt b/BackupList.txt index e6f2f98..4c9fa2d 100644 --- a/BackupList.txt +++ b/BackupList.txt @@ -1,27 +1,39 @@ # BackupList.txt —— 备份 / 恢复共用清单 # -# 语法: -# <软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ] +# 每一行支持**两种写法**,混用没问题: # -# 三种写法都可以,混用也没问题: -# -# 1. 软件名(推荐)—— 去 SoftwareCatalog.psd1 里查目录,归档名就是软件名 +# 1. 软件名(推荐)—— 去 SoftwareCatalog.psd1 查目录,归档名就是软件名 # FooClolor -# Kazumi :: !*Cache +# scoop +# Kazumi :- !*Cache # -# 2. 字面路径 —— 含 `\`、`/` 或 `%` 就按路径处理,归档名沿用 <名>_from_<路径> +# 2. 用户手写的目录 —— 含 `\`、`/` 或 `%` 就按路径处理,归档名沿用 <名>_from_<路径> # %UserProfile%\Documents\PowerShell -# C:\Programs\MiFlash +# C:\Programs\MiFlash :- MiFlash\logs\ # -# 3. 软件名 + @pathname —— 强制用旧的路径命名算法 +# 3. 软件名 + @pathname —— 强制用旧的路径命名算法(归档名从路径算) # FooClolor @pathname # +# 两种写法都支持**追加**与**排除**: +# +# :+ 追加一个目录;写成软件名时会按名录展开成它的全部目录 +# %UserProfile%\Documents\PowerShell :+ D:\backup\ps-extra +# MiFlash :+ MiFlash_Unlock +# :+ 可以出现多次、位置随意;追加进来的目录与主目录一起打进同一个归档。 +# +# :- 排除模式(`::` 是它的历史别名,两者等价) +# Edge :- !*Cache,Default\Extensions +# `,` 与 `;` 都当分隔符。 +# +# 行尾可以写 `# 说明` 讲清这条为什么这么配;运行时会把它和目录介绍一起打印出来: +# Edge :- !*Cache # 缓存可再生,不进归档 +# # 排除模式:相对归档根目录。以 ! 开头表示"任意层级下匹配这个组件名"(7z 的 -xr!)。 # 不要自己写引号;模式里的空格会被自动转成 ?(7z 的模式不支持空格)。 # 标记: # encrypt 用 7z 加密该归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile) # pathname 用路径命名算法而不是软件名 -# root=<名> 覆盖归档内的根目录名(默认就是软件名) +# root=<名> 尚未实现(归档内根目录始终是源目录名),用了会告警 # # 归档名 = 软件名,所以:**同一个软件不要写两遍**,脚本会直接报重复错误。 # 软件名(连同排除规则、加密标记)都维护在 SoftwareCatalog.psd1 和本文件里, @@ -30,13 +42,14 @@ # ---- 用户配置 / 开发环境 ---- legendary opencode -scoop-config +# scoop 是一个软件名 + 对象数组(见 SoftwareCatalog.psd1):一个 scoop.7z 里 +# 同时装 %UserProfile%\scoop\persist 与 %UserProfile%\.config\scoop。 +scoop # scoop 各应用的持久化数据 + scoop 自身配置 # .ssh 里是私钥。想加密就把下面那行 @encrypt 的注释互换(见 README「加密」) .ssh -CodeSpace :: Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\ +CodeSpace :- Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\ # 排除同一仓库里的源码树 PowerShell WindowsPowerShell -scoop-persist # ---- 应用数据 ---- AutoDarkMode @@ -63,7 +76,7 @@ twinkle-tray # # 注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies), # 脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 Edge。 -Edge :: !*Cache,!component_crx_cache,!ProvenanceData,!optimization_guide,!Crashpad,!BrowserMetrics,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,Snapshots,Edge Sidebar,Edge Shopping +Edge :- !*Cache,!component_crx_cache,!ProvenanceData,!optimization_guide,!Crashpad,!BrowserMetrics,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,Snapshots,Edge Sidebar,Edge Shopping # 下面这些全是可再生数据:缓存/组件缓存/SW/扩展本体/遥测与优化 WindowsTerminal # ---- 系统 ---- @@ -72,12 +85,12 @@ Startup # ---- C:\Programs ---- BaiduNetdisk FooClolor -March7thAssistant :: 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\ +March7thAssistant :- 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\ # 内置浏览器的缓存与日志,可再生 MiFlash MiFlash_Unlock QuarkCloudDrive translucenttb -ScoopApps-persist :: persist\ariang-native\UserData\DawnCache,persist\ariang-native\UserData\GPUCache,persist\ariang-native\UserData\Local Storage,persist\ariang-native\UserData\Session Storage +ScoopApps-persist :- persist\ariang-native\UserData\DawnCache,persist\ariang-native\UserData\GPUCache,persist\ariang-native\UserData\Local Storage,persist\ariang-native\UserData\Session Storage # ariang 的缓存/会话数据,可再生 # ---- 其它盘 ---- Aria diff --git a/Common.psm1 b/Common.psm1 index a9c7a09..44cf41b 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -392,6 +392,17 @@ function ConvertFrom-BackupListLine { return $null } + # 行内注释:`#` 前面有空白时,它后面整段是"这条为什么这么配"的说明。 + # 解析时摘出来单独放在 Comment 里,运行时打印,让人一眼看懂排除/追加的理由。 + # (路径里的 `#` 必须紧贴前一个字符,所以 `C:\a#b` 不会受影响。) + $comment = $null + $commentIndex = $content.IndexOf(' #') + if ($commentIndex -ge 0) { + $comment = $content.Substring($commentIndex + 1).Trim().TrimStart('#').Trim() + $content = $content.Substring(0, $commentIndex).Trim() + if ([string]::IsNullOrEmpty($content)) { return $null } + } + # 行内记号(都用到 `:`,因为 `:` 在 Windows 路径里只可能是盘符, # 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响): # :: 排除模式(历史写法,等价于 :-) @@ -477,6 +488,7 @@ function ConvertFrom-BackupListLine { AddedPaths = $addedPaths ExcludePatterns = $excludes Flags = $flags + Comment = $comment Raw = $Line } } @@ -656,105 +668,123 @@ function Get-SoftwareCatalog { $entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key } + # 一个软件可以对应**多个目录**,写成数组;数组元素两种都认: + # * 对象(推荐):@{ Path = '<目录>'; Description = '<这个目录是干什么的>' } + # * 纯字符串: '<目录>' + # 也兼容字典写法:@{ Dirs = @(...) } / @{ Variants = @(...) } / @{ Path = '<目录>' } $rawPath = $null - $dirList = @() + $candidates = @() + $entryDescription = $null + if ($entry -is [System.Collections.IDictionary]) { - # Dirs = 一个软件包含的多个目录(推荐写法) - # Variants = 同名目录出现在多个位置(旧写法,等价于 Dirs,保留兼容) - if ($entry.Contains('Dirs')) { $dirList = @($entry['Dirs']) } - elseif ($entry.Contains('Variants')) { $dirList = @($entry['Variants']) } + foreach ($key in 'Description', 'Note', 'Desc') { + if ($entry.Contains($key)) { $entryDescription = [string]$entry[$key]; break } + } + if ($entry.Contains('Dirs')) { $candidates = @($entry['Dirs']) } + elseif ($entry.Contains('Variants')) { $candidates = @($entry['Variants']) } if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] } - } else { + } elseif ($entry -is [string]) { + $rawPath = $entry + } elseif ($entry -is [System.Collections.IEnumerable]) { + $candidates = @($entry) + } elseif ($null -ne $entry) { $rawPath = [string]$entry } - if (-not $rawPath -and $dirList.Count -eq 0) { continue } - $resolved = if ($rawPath) { [Environment]::ExpandEnvironmentVariables($rawPath) } else { $null } - - # 多目录:逐个解析(每个都可以用前缀补全),任一解析不出来就整体 Unresolved - if ($dirList.Count -gt 0) { - $paths = @() - $unresolved = @() - foreach ($item in $dirList) { - $candidate = [Environment]::ExpandEnvironmentVariables([string]$item) - if (Test-Path -LiteralPath $candidate) { - $paths += $candidate - continue - } - $parent = Split-Path -Path $candidate -Parent - $leaf = Split-Path -Path $candidate -Leaf - $found = $null - if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) { - $found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth) - } - if ($found -and $found.Count -gt 0) { - $paths += $found[0] - Write-Log "名录:$name 的 $candidate -> $($found[0])(按前缀补全)" -Level DEBUG - } else { - $unresolved += $candidate - } - } - - $kind = if ($paths.Count -eq 0) { 'Unresolved' } elseif ($unresolved.Count -gt 0) { 'Partial' } else { 'Multi' } - if ($unresolved.Count -gt 0) { - Write-Log ("名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $unresolved.Count, ($unresolved -join ';')) -Level WARN - } - - $result[$name] = [pscustomobject]@{ - Name = $name - Path = $rawPath - ResolvedPath = $(if ($paths.Count -gt 0) { $paths[0] } else { $resolved }) - Variants = $paths - Dirs = $paths - Missing = $unresolved - Kind = $kind - Raw = $entry - } - Write-Log "名录:$name -> $($paths.Count) 个目录" -Level DEBUG - continue + # 单目录写法:包成对象,好让"目录说明"跟目录一起走下去 + if ($candidates.Count -eq 0 -and $rawPath) { + $candidates = @([pscustomobject]@{ Path = $rawPath; Description = $entryDescription }) } + if ($candidates.Count -eq 0) { continue } + + # 逐个候选目录解析。**声明了几个就记几个**,找不到的也留着: + # 备份端按存在性跳过它们,恢复端要靠它们把内容还原回原位。 + $items = @() + foreach ($candidate in $candidates) { + $candidatePath = $null + $candidateDescription = $null + + if ($candidate -is [string]) { + $candidatePath = $candidate + } elseif ($candidate -is [System.Collections.IDictionary]) { + foreach ($key in 'Path', 'Dir', 'Directory') { + if ($candidate.Contains($key)) { $candidatePath = [string]$candidate[$key]; break } + } + foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') { + if ($candidate.Contains($key)) { $candidateDescription = [string]$candidate[$key]; break } + } + } elseif ($null -ne $candidate) { + # JSON 里是对象、.psd1 里一般是哈希表,两种都认 + $names = @($candidate.PSObject.Properties.Name) + foreach ($key in 'Path', 'Dir', 'Directory') { + if ($names -contains $key) { $candidatePath = [string]$candidate.$key; break } + } + foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') { + if ($names -contains $key) { $candidateDescription = [string]$candidate.$key; break } + } + } + + if ([string]::IsNullOrWhiteSpace($candidatePath)) { continue } + $declared = ([Environment]::ExpandEnvironmentVariables([string]$candidatePath)).Trim() + if (-not $declared) { continue } + + if (Test-Path -LiteralPath $declared) { + $items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $true; Suffixed = $false; Description = $candidateDescription } + continue + } - if (Test-Path -LiteralPath $resolved) { - $kind = 'Single' - } else { # 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 _2.0.4) - $parent = Split-Path -Path $resolved -Parent - $leaf = Split-Path -Path $resolved -Leaf + $parent = Split-Path -Path $declared -Parent + $leaf = Split-Path -Path $declared -Leaf $found = $null - if ($parent -and (Test-Path -LiteralPath $parent)) { + if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) { $found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth) - if ($found.Count -gt 1) { $kind = 'Multi' } elseif ($found.Count -eq 1) { $kind = 'Single' } else { $kind = 'Unresolved' } - } else { - $kind = 'Unresolved' } if ($found -and $found.Count -gt 0) { - $resolved = $found[0] - $result[$name] = [pscustomobject]@{ - Name = $name - Path = $rawPath - ResolvedPath = $resolved - Variants = @($found) - Dirs = @($found) - Missing = @() - Kind = $kind - Raw = $entry - Suffixed = $true + foreach ($match in $found) { + $items += [pscustomobject]@{ Declared = $declared; Resolved = $match; Exists = $true; Suffixed = $true; Description = $candidateDescription } } - Write-Log "名录:$name -> $resolved(按前缀补全)" -Level DEBUG - continue + Write-Log "名录:$name 的 $declared -> $($found -join '、')(按前缀补全)" -Level DEBUG + } else { + # 找不到也留着:恢复时这正是"要把数据放回去"的那个位置 + $items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $false; Suffixed = $false; Description = $candidateDescription } } } + if ($items.Count -eq 0) { continue } + + $existing = @($items | Where-Object { $_.Exists } | ForEach-Object { $_.Resolved }) + $missing = @($items | Where-Object { -not $_.Exists } | ForEach-Object { $_.Declared }) + $declaredList = @($items | ForEach-Object { $_.Declared }) + + $kind = if ($existing.Count -eq 0) { 'Unresolved' } + elseif ($missing.Count -gt 0) { 'Partial' } + elseif ($existing.Count -gt 1) { 'Multi' } + else { 'Single' } + + if ($missing.Count -gt 0) { + # 多目录条目里少了一个目录值得告警(整包少了一块); + # 单目录条目少目录是常规情况(软件没装),备份端会明确说"跳过: X,源路径不存在", + # 这里降成 DEBUG,免得每个条目都刷两遍同样的警告。 + $missingText = "名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $missing.Count, ($missing -join ';') + if ($items.Count -gt 1) { Write-Log $missingText -Level WARN } else { Write-Log $missingText -Level DEBUG } + } + Write-Log ("名录:{0} -> 声明 {1} 个目录,其中存在 {2} 个" -f $name, $items.Count, $existing.Count) -Level DEBUG $result[$name] = [pscustomobject]@{ Name = $name - Path = $rawPath - ResolvedPath = $resolved - Variants = @() - Dirs = $(if ($kind -eq 'Single') { @($resolved) } else { @() }) - Missing = @() + # Path 保留"名录里写的那个字符串"。数组写法没有唯一字符串,取第一个候选项。 + Path = $(if ($rawPath) { $rawPath } else { $declaredList[0] }) + ResolvedPath = $(if ($existing.Count -gt 0) { $existing[0] } else { $missing[0] }) + Variants = $existing + Dirs = $existing + Declared = $declaredList + Items = $items + Missing = $missing + Description = $entryDescription Kind = $kind Raw = $entry + Suffixed = [bool](@($items | Where-Object { $_.Suffixed }).Count) } } @@ -879,6 +909,46 @@ function Get-ItemArchiveName { return Get-BackupBaseName -RawPath $Entry.Path } +function New-BackupSourceItem { + <# + .SYNOPSIS + 把一条路径整理成"要打包的一个源",并带上给人看的说明。 + + .DESCRIPTION + 返回 @{ RootName; ParentDir; RelativePaths; SourcePath; Description; Origin }, + 拆不出父目录或末级名时返回 $null(相对路径、盘符根目录之类)。 + + 归档里的布局是"以 ParentDir 为工作目录、把 RelativePaths 加进去", + 所以 RelativePaths 既是**文件系统上的名字**,也是**归档里的顶层名字**。 + 7z 命令行没有"入库时改名"的能力,这两个名字只能是同一个 —— 因此 + Resolve-BackupEntry 会拦下"同一条目里两个同名目录"的情况。 + + Origin 说明这个源是怎么来的(catalog / path / append-catalog / append-path), + 运行时会打印出来,方便回答"这个目录为什么会被备份"。 + #> + param( + [string]$Path, + $RootName = $null, + $Description = $null, + [string]$Origin = 'catalog' + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + + $parent = Split-Path -Path $Path -Parent + $leaf = Split-Path -Path $Path -Leaf + if (-not $parent -or -not $leaf) { return $null } + + return [pscustomobject]@{ + RootName = $RootName + ParentDir = $parent + RelativePaths = @($leaf) + SourcePath = $Path + Description = $Description + Origin = $Origin + } +} + function Resolve-BackupEntry { <# .SYNOPSIS @@ -910,158 +980,177 @@ function Resolve-BackupEntry { $baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth $rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) }) - # 必须在下面任何一个 return 之前算出来:名录里没有这个名字时也要走 - # "路径不存在" 分支,那条分支引用 $rootName。原先它写在后面,靠 PowerShell - # 的隐式 $null 侥幸不报错,但会把**外层作用域**残留的 $rootName 带进来。 + # 必须在下面任何一个分支之前算出来:名录里没有这个名字时也要用它, + # 否则会读到调用方作用域里残留的 $rootName(PowerShell 是动态作用域)。 $rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName } - if (-not $isName) { - $sourcePath = [Environment]::ExpandEnvironmentVariables($Entry.Path) - return [pscustomobject]@{ - IsName = $false - CatalogEntry = $null - BaseName = $baseName - ArchiveFlavor = 'path' - RootName = $null - Sources = @([pscustomobject]@{ RootName = $null; ParentDir = (Split-Path -Path $sourcePath -Parent); RelativePaths = @((Split-Path -Path $sourcePath -Leaf)); SourcePath = $sourcePath }) - Source = $Entry.Path - } - } - - $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth - if (-not $catalog.ContainsKey($Entry.Path)) { - return [pscustomobject]@{ - IsName = $true - CatalogEntry = $null - BaseName = $baseName - ArchiveFlavor = 'name' - RootName = $rootName - Sources = @() - Source = $Entry.Path - Error = "软件名录里没有 '$($Entry.Path)'" - } - } - - $catalogEntry = $catalog[$Entry.Path] - - # @pathname 必须排在 Unresolved 分支之前:否则路径不存在的条目会先被 - # 当作普通软件名条目返回 Flavor='name',把 @pathname 覆盖悄悄吃掉。 - if ($forcePathFlavor) { - $flavorPath = $catalogEntry.ResolvedPath - $flavorParent = Split-Path -Path $flavorPath -Parent - $flavorLeaf = Split-Path -Path $flavorPath -Leaf - $flavorSource = @() - if ($flavorParent -and $flavorLeaf) { - $flavorSource = @([pscustomobject]@{ - RootName = $null - ParentDir = $flavorParent - RelativePaths = @($flavorLeaf) - SourcePath = $flavorPath - }) - } - return [pscustomobject]@{ - IsName = $true - CatalogEntry = $catalogEntry - BaseName = $baseName - ArchiveFlavor = 'path' - RootName = $null - Sources = $flavorSource - Source = $Entry.Path - Error = $(if ($catalogEntry.Kind -eq 'Unresolved') { "名录里的路径不存在:$($catalogEntry.Path)" } else { $null }) - } - } - - # 名录里的路径当前不存在。备份时这是"跳过",但**恢复时这正是要恢复的场景**, - # 所以 SourcePath 仍然给出来(恢复端会照它把内容还原回原位), - # 只把 Error 标出来让备份端跳过。 - if ($catalogEntry.Kind -eq 'Unresolved') { - $missingPath = $catalogEntry.ResolvedPath - $missingParent = Split-Path -Path $missingPath -Parent - $missingLeaf = Split-Path -Path $missingPath -Leaf - $missingSource = @() - if ($missingParent -and $missingLeaf) { - $missingSource = @([pscustomobject]@{ - RootName = $rootName - ParentDir = $missingParent - RelativePaths = @($missingLeaf) - SourcePath = $missingPath - }) - } - return [pscustomobject]@{ - IsName = $true - CatalogEntry = $catalogEntry - BaseName = $baseName - ArchiveFlavor = 'name' - RootName = $rootName - Sources = $missingSource - Source = $Entry.Path - Error = "名录里的路径不存在:$($catalogEntry.Path)" - } - } - - # @pathname 已在前面统一处理(必须排在 Unresolved 之前),这里不再重复。 - + $catalogEntry = $null $sources = @() + $archiveFlavor = 'name' + $errorText = $null - if ($catalogEntry.Kind -in @('Multi', 'Partial')) { - # 一个软件包含多个目录:每个目录各占归档里的一个根目录层。 - # 归档内层用**源目录自己的名字**(不用软件名),这样恢复时 - # 每个目录都能各自找到父目录与末级名,互不干扰。 - foreach ($dir in $catalogEntry.Dirs) { - $dirParent = Split-Path -Path $dir -Parent - $dirLeaf = Split-Path -Path $dir -Leaf - if (-not $dirParent -or -not $dirLeaf) { continue } - $sources += [pscustomobject]@{ - RootName = $null - ParentDir = $dirParent - RelativePaths = @($dirLeaf) - SourcePath = $dir - } + if (-not $isName) { + # ---- 写法二:用户手写的目录 / 文件(含 \ / 或 % 就按路径处理)---- + $archiveFlavor = 'path' + $source = New-BackupSourceItem -Path ([Environment]::ExpandEnvironmentVariables($Entry.Path)) -Origin 'path' + if ($source) { $sources += $source } + } + else { + # ---- 写法一:软件名录里的软件名 ---- + $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth + if (-not $catalog.ContainsKey($Entry.Path)) { + $errorText = "软件名录里没有 '$($Entry.Path)'" } - } elseif ($catalogEntry.Kind -eq 'Single') { - $sources = @([pscustomobject]@{ - RootName = $rootName - ParentDir = (Split-Path -Path $catalogEntry.ResolvedPath -Parent) - RelativePaths = @((Split-Path -Path $catalogEntry.ResolvedPath -Leaf)) - SourcePath = $catalogEntry.ResolvedPath - }) - } else { - return [pscustomobject]@{ - IsName = $true - CatalogEntry = $catalogEntry - BaseName = $baseName - ArchiveFlavor = 'name' - RootName = $rootName - Sources = @() - Source = $Entry.Path - Error = "名录里的路径不存在:$($catalogEntry.Path)" + else { + $catalogEntry = $catalog[$Entry.Path] + # 单目录条目的 RootName 沿用软件名(历史行为);多目录留空 + $catalogRootName = if ($catalogEntry.Kind -eq 'Single') { $rootName } else { $null } + + if ($forcePathFlavor) { + # @pathname:归档名走路径算法,包内布局按源目录名 + $archiveFlavor = 'path' + $source = New-BackupSourceItem -Path $catalogEntry.ResolvedPath -Origin 'catalog' + if ($source) { $sources += $source } + if ($catalogEntry.Kind -eq 'Unresolved') { $errorText = "名录里的路径不存在:$($catalogEntry.Path)" } + } + else { + # 名录里声明了几个目录就产出几个源 —— **当前不存在的那几个也要留着**: + # 恢复时正是要靠它们把内容还原回原位;备份端按存在性自己跳过。 + foreach ($item in $catalogEntry.Items) { + $source = New-BackupSourceItem -Path $item.Resolved -RootName $catalogRootName ` + -Description $item.Description -Origin 'catalog' + if ($source) { $sources += $source } + } + if ($catalogEntry.Kind -eq 'Unresolved') { + $errorText = "名录里的路径不存在:$($catalogEntry.Path)" + } elseif ($catalogEntry.Kind -eq 'Partial') { + $errorText = ("名录里有 {0} 个目录当前不存在,备份会跳过它们:{1}" -f ` + $catalogEntry.Missing.Count, ($catalogEntry.Missing -join ';')) + } + } } } - # 清单里的 `:+ <路径>` 追加项,叠加在名录的目录之后 - if ($Entry.AddedPaths -and $Entry.AddedPaths.Count -gt 0) { - foreach ($added in $Entry.AddedPaths) { - $addedPath = [Environment]::ExpandEnvironmentVariables($added) - $addedParent = Split-Path -Path $addedPath -Parent - $addedLeaf = Split-Path -Path $addedPath -Leaf - if (-not $addedParent -or -not $addedLeaf) { continue } - $sources += [pscustomobject]@{ - RootName = $null - ParentDir = $addedParent - RelativePaths = @($addedLeaf) - SourcePath = $addedPath + # ------------------------------------------------------------------ + # 追加段:`:+ <路径 或 软件名>`,**两种写法都生效** + # 以前这一节只接在"软件名且能解析出目录"的那条路径后面,手写路径的 :+ 会被整段丢掉。 + # ------------------------------------------------------------------ + foreach ($added in @($Entry.AddedPaths)) { + if ([string]::IsNullOrWhiteSpace([string]$added)) { continue } + $addedText = ([string]$added).Trim() + $addedPath = [Environment]::ExpandEnvironmentVariables($addedText) + + if (-not (Test-LiteralPath -Path $addedPath)) { + # 写得像软件名:按名录展开成它的全部目录 + $addedCatalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth + if ($addedCatalog.ContainsKey($addedPath)) { + foreach ($item in $addedCatalog[$addedPath].Items) { + $source = New-BackupSourceItem -Path $item.Resolved -Description $item.Description -Origin 'append-catalog' + if ($source) { $sources += $source } + } + continue } + Write-Log "追加项 '$addedText' 既不是字面路径,也不在软件名录里,已忽略" -Level WARN + continue } + + $source = New-BackupSourceItem -Path $addedPath -Origin 'append-path' + if ($source) { $sources += $source } + else { Write-Log "追加项无法拆出父目录与末级名,已忽略:$addedText" -Level WARN } + } + + # ------------------------------------------------------------------ + # 归档内顶层同名冲突拦截 + # 7z 加进来的路径,在归档里就是**文件系统上的那个名字**(命令行没有"入库改名"的能力)。 + # 所以同一个条目里出现两个同名目录(例如两个 persist)时,它们在包内会混成一棵树, + # 解出来两边的内容都是错的。宁可明确报错,也不要静默搅在一起。 + # ------------------------------------------------------------------ + $seenTop = @{} + $collisions = @() + foreach ($source in $sources) { + $top = @($source.RelativePaths)[0] + if (-not $top) { continue } + if ($seenTop.ContainsKey($top)) { + $collisions += ("'{0}'({1} 与 {2})" -f $top, $seenTop[$top], $source.SourcePath) + } else { + $seenTop[$top] = $source.SourcePath + } + } + + $blocking = $null + if ($collisions.Count -gt 0) { + $blocking = ("归档内顶层同名,无法区分:{0}。7z 不能把同一个源在包内改名,它们会在归档里混成一棵树;" + + "请把它们拆成两个独立条目(各自一个归档)。") -f ($collisions -join ';') } return [pscustomobject]@{ - IsName = $true + IsName = [bool]$isName CatalogEntry = $catalogEntry BaseName = $baseName - ArchiveFlavor = 'name' - RootName = $rootName - Sources = $sources + ArchiveFlavor = $archiveFlavor + RootName = $(if ($isName -and -not $forcePathFlavor) { $rootName } else { $null }) + Sources = @($sources) Source = $Entry.Path + Error = $errorText + Blocking = $blocking + } +} + +function Write-BackupEntryPlan { + <# + .SYNOPSIS + 在动手打包之前,把"这个条目会打包哪些目录、排除了什么、为什么"打印出来。 + + .DESCRIPTION + 目录说明来自 SoftwareCatalog;排除 / 追加的**来源**来自清单,逐项打印: + * 每个目录一行:路径、它是怎么来的(名录 / 手写路径 / :+ 追加)、 + 当前在不在、以及这个目录是干什么的(Description); + * 排除模式按来源分组打印:清单的 :- 段、BackupConfig.psd1 的 DefaultExcludes; + * 清单行尾的 `# 说明` 作为这条目的整体说明打印出来。 + #> + param( + [Parameter(Mandatory = $true)]$Resolved, + [Parameter(Mandatory = $true)][string]$DisplayPath, + [string[]]$ListExcludes = @(), + [string[]]$ConfigExcludes = @(), + [string]$Comment + ) + + $originText = @{ + 'catalog' = '软件名录' + 'path' = '手写路径' + 'append-catalog' = '清单 :+ 追加(按软件名录展开)' + 'append-path' = '清单 :+ 追加(字面路径)' + } + + Write-Log ("条目:{0}" -f $DisplayPath) + Write-Log (" 归档:{0}" -f $Resolved.BaseName) + if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) } + if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN } + + $sources = @($Resolved.Sources) + if ($sources.Count -eq 0) { + Write-Log ' 目录:没有解析出任何目录' -Level WARN + } + + for ($index = 0; $index -lt $sources.Count; $index++) { + $source = $sources[$index] + $exists = Test-Path -LiteralPath $source.SourcePath + $origin = if ($source.Origin -and $originText.ContainsKey($source.Origin)) { $originText[$source.Origin] } else { $source.Origin } + + Write-Log (" 目录 {0}/{1}:{2}" -f ($index + 1), $sources.Count, $source.SourcePath) + Write-Log (" 来源:{0};{1}" -f $origin, $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' })) + if ($source.Description) { Write-Log (" 介绍:{0}" -f $source.Description) } + } + + if ($ListExcludes.Count -gt 0) { + Write-Log (" 排除 {0} 条(来自清单的 :- 段):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、')) + } + if ($ConfigExcludes.Count -gt 0) { + Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、')) + } + if ($ListExcludes.Count -eq 0 -and $ConfigExcludes.Count -eq 0) { + Write-Log ' 排除:无(整包收下)' } } @@ -1397,7 +1486,7 @@ Export-ModuleMember -Function @( 'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath', 'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName', 'Get-ArchiveTopLevelNames', - 'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', + 'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary', 'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace', 'Get-BaknretConfig', 'Get-BaknretPassword' diff --git a/README.md b/README.md index 42132dc..33aaead 100644 --- a/README.md +++ b/README.md @@ -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` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。 diff --git a/Restore.ps1 b/Restore.ps1 index 138b8e9..4749240 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -158,7 +158,7 @@ function Get-7zExecutable { } function Invoke-Extraction { - param([object]$ArchiveFile, [string]$DestinationPath) + param([object]$ArchiveFile, [string]$DestinationPath, [string]$RelativePath) $extension = $ArchiveFile.Extension.ToLower() $destParent = Split-Path -Path $DestinationPath -Parent @@ -167,14 +167,17 @@ function Invoke-Extraction { New-Item -ItemType Directory -Path $destParent -Force | Out-Null } - # 归档布局与历史保持一致:根目录就是源目录名(软件名条目也一样, - # 软件名只用于归档文件名),因此直接整包解压到目标的父目录即可。 + # 归档布局与历史保持一致:顶层就是**源目录名**(软件名只用于归档文件名)。 + # 一个条目可能打包了好几个目录(软件名录里的数组写法 / `:+` 追加), + # 所以**不能整包往每个目标里倒** —— 那会把兄弟目录也复制到不相干的父目录下。 + # 这里只解出该目标自己那棵子树($RelativePath),其余不动。 $sevenZip = Get-7zExecutable if ($sevenZip) { Write-Log '使用 7z 解压' -Level DEBUG $argument = @('x', '-bsp2', '-y', "-o$destParent") if ($password) { $argument += "-p$password" } $argument += $ArchiveFile.FullName + if ($RelativePath) { $argument += $RelativePath } $exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" } @@ -187,18 +190,24 @@ function Invoke-Extraction { if (-not $rarExe) { throw '未找到 RAR 工具' } Write-Log '使用 RAR 解压' -Level DEBUG $argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\") + if ($RelativePath) { $argument += $RelativePath } $exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" } } '.zip' { Write-Log '使用内置 ZIP 解压' -Level DEBUG + if ($RelativePath) { + Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN + } Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force } '.tar' { $tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source if (-not $tarExe) { throw '未找到 TAR 工具' } Write-Log '使用 TAR 解压' -Level DEBUG - $exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList @('-xf', $ArchiveFile.FullName, '-C', $destParent) + $argument = @('-xf', $ArchiveFile.FullName, '-C', $destParent) + if ($RelativePath) { $argument += $RelativePath } + $exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" } } default { throw "不支持的文件格式:$extension" } @@ -305,14 +314,26 @@ foreach ($line in $lines) { continue } - # 恢复目的地。清单条目可能带多个源(同名目录分散在多处),每个源各恢复各的。 + # 恢复目的地。一个条目可能带多个源(软件名录里的数组写法、`:+` 追加、 + # 或同名目录分散在多处),每个源只还原**它自己那棵子树**。 $targets = @() if ($resolved.Sources.Count -gt 0) { foreach ($source in $resolved.Sources) { - $targets += [pscustomobject]@{ DestPath = $source.SourcePath } + $targets += [pscustomobject]@{ + DestPath = $source.SourcePath + RelativePath = @($source.RelativePaths)[0] + Description = $source.Description + Origin = $source.Origin + } } } else { - $targets += [pscustomobject]@{ DestPath = [Environment]::ExpandEnvironmentVariables($displayPath) } + $expanded = [Environment]::ExpandEnvironmentVariables($displayPath) + $targets += [pscustomobject]@{ + DestPath = $expanded + RelativePath = (Split-Path -Path $expanded -Leaf) + Description = $null + Origin = 'path' + } } # 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path @@ -388,6 +409,16 @@ foreach ($line in $lines) { } $plannedTargets = @($targets | Where-Object { $_.DestPath }) + + # 说清楚"这条会把哪些目录还原到哪儿、为什么" + Write-Log ("恢复计划:{0}(归档 {1})" -f $displayPath, $archiveFile.Name) + foreach ($target in $plannedTargets) { + $targetExists = Test-Path -LiteralPath $target.DestPath + Write-Log (" 目标:{0}" -f $target.DestPath) + Write-Log (" 归档内子树:{0};{1}" -f $target.RelativePath, $(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' })) + if ($target.Description) { Write-Log (" 介绍:{0}" -f $target.Description) } + } + foreach ($target in $plannedTargets) { if (Test-Path -LiteralPath $target.DestPath) { continue } # Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建 @@ -415,7 +446,7 @@ foreach ($line in $lines) { $restoreFailed = $false try { foreach ($target in $plannedTargets) { - if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath)) { + if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath -RelativePath $target.RelativePath)) { $restoreFailed = $true break } diff --git a/SoftwareCatalog.psd1 b/SoftwareCatalog.psd1 index a73593a..b63b566 100644 --- a/SoftwareCatalog.psd1 +++ b/SoftwareCatalog.psd1 @@ -31,7 +31,7 @@ 1. 目录不存在时会按前缀补全:写 'D:\Programs\legendary',实际目录是 'D:\Programs\legendary_2.0.4',会自动匹配(只认 `<名>_*` 与 `<名>-*`, 不会把 Legendary 误配成 LegendarySomething)。 - 2. **一个软件包含多个目录**时,用 Dirs 数组列出,全部打进同一个归档: + 2. **一个软件包含多个目录**时,写成**对象数组**(每个目录带自己的说明),全部打进同一个归档: scoop = @{ Dirs = @( '%UserProfile%\scoop\persist' @@ -39,8 +39,13 @@ '%UserProfile%\.config\scoop' ) } - 归档里每个目录仍是自己的名字与层级,恢复时各自还原回原位。 - 同名目录出现在多个位置的旧写法 Variants 等价于 Dirs,继续可用。 + 归档里每个目录仍是自己的名字与层级,恢复时会**只解出该目录自己那棵子树**, + 各自还原回原位,不会把兄弟目录也复制过去。 + 纯字符串数组、以及旧的 `@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法继续可用。 + + 注意:归档内的顶层名就是目录自己的名字,所以**同一个软件里不能有两个同名目录** + (典型例子是两个都叫 persist 的目录)。那种情况脚本会明确报错并让你拆成两个条目, + 而不是把两棵树悄悄混在一起。 分文件维护:用 Includes 引入其它名录文件(路径相对本文件): @@ -50,41 +55,103 @@ } #> @{ + # 每个条目有两种写法: + # 1. 只写一个目录字符串: legendary = '%UserProfile%\.config\legendary' + # 2. 带目录介绍(推荐): + # legendary = @{ + # Path = '%UserProfile%\.config\legendary' + # Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录' + # } + # 一个软件包含**多个目录**时,写成对象数组(见下面的 scoop)。 + # 运行时会把"这个条目打包哪些目录、每个目录是干什么的、排除了什么、为什么" + # 逐条打印出来,说明就来自这里。 + # ---- 用户配置 / 开发环境 ---- # 含 `-` 或 `.` 的键必须加引号,否则会被当成减法表达式(见文件开头说明) - legendary = '%UserProfile%\.config\legendary' - opencode = '%UserProfile%\.config\opencode' - 'scoop-config' = '%UserProfile%\.config\scoop' - 'scoop-persist' = '%UserProfile%\scoop\persist' - '.ssh' = '%UserProfile%\.ssh' - CodeSpace = 'D:\UserData\Documents\CodeSpace' - PowerShell = '%UserProfile%\Documents\PowerShell' - WindowsPowerShell = '%UserProfile%\Documents\WindowsPowerShell' + legendary = @{ + Path = '%UserProfile%\.config\legendary' + Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录' + } + opencode = @{ + Path = '%UserProfile%\.config\opencode' + Description = 'opencode 的配置' + } + + # 一个软件 = 一个归档;多个目录写成**对象数组**,每个目录各自带说明。 + # 注意:归档内的顶层名字就是**目录自己的名字**,所以同一个软件里不能有两个同名目录 + # (例如两个 persist)—— 那会在包里混成一棵树,脚本会明确报错让你拆成两个条目。 + scoop = @( + @{ + Path = '%UserProfile%\scoop\persist' + Description = 'scoop 里各应用的持久化数据(重装应用就会丢,必须备份)' + } + @{ + Path = '%UserProfile%\.config\scoop' + Description = 'scoop 自身的配置(源、代理、已安装清单)' + } + ) + + '.ssh' = @{ + Path = '%UserProfile%\.ssh' + Description = 'SSH 私钥 / 公钥 / known_hosts(不可再生;要加密就给清单里那行加 @encrypt)' + } + CodeSpace = @{ + Path = 'D:\UserData\Documents\CodeSpace' + Description = '开发代码目录' + } + PowerShell = @{ + Path = '%UserProfile%\Documents\PowerShell' + Description = 'PowerShell 7 的用户配置与模块' + } + WindowsPowerShell = @{ + Path = '%UserProfile%\Documents\WindowsPowerShell' + Description = 'Windows PowerShell 5.1 的用户配置与模块' + } # ---- 应用数据 ---- - AutoDarkMode = '%AppData%\AutoDarkMode' - Kazumi = '%AppData%\com.example\Kazumi' - piliplus = '%AppData%\com.example\piliplus' - fnm = '%AppData%\fnm' - 'twinkle-tray' = '%AppData%\twinkle-tray' + AutoDarkMode = @{ Path = '%AppData%\AutoDarkMode'; Description = 'AutoDarkMode 的主题/时间设置' } + Kazumi = @{ Path = '%AppData%\com.example\Kazumi'; Description = 'Kazumi 的观看记录与设置' } + piliplus = @{ Path = '%AppData%\com.example\piliplus'; Description = 'piliplus 的设置与账号数据' } + fnm = @{ Path = '%AppData%\fnm'; Description = 'fnm(Node 版本管理器)的版本记录' } + 'twinkle-tray' = @{ Path = '%AppData%\twinkle-tray'; Description = 'Twinkle Tray 的显示器亮度设置' } # ---- 浏览器与终端 ---- - Edge = '%LocalAppData%\Microsoft\Edge\User Data' - WindowsTerminal = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + # Edge 的缓存/扩展本体等可再生内容由 BackupList.txt 的 :- 排除规则挡掉 + Edge = @{ + Path = '%LocalAppData%\Microsoft\Edge\User Data' + Description = 'Edge 用户数据:书签、密码、Cookies、历史、站点数据' + } + WindowsTerminal = @{ + Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Description = 'Windows Terminal 的设置文件' + } # ---- 系统 ---- - Startup = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup' + Startup = @{ + Path = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup' + Description = '全局开机启动项(快捷方式)' + } # ---- C:\Programs ---- - BaiduNetdisk = 'C:\Programs\BaiduNetdisk' - FooClolor = 'C:\Programs\FooClolor' - March7thAssistant = 'C:\Programs\March7thAssistant' - MiFlash = 'C:\Programs\MiFlash' - MiFlash_Unlock = 'C:\Programs\MiFlash_Unlock' - QuarkCloudDrive = 'C:\Programs\QuarkCloudDrive' - translucenttb = 'C:\Programs\ScoopApps\apps\translucenttb\current\settings.json' - 'ScoopApps-persist' = 'C:\Programs\ScoopApps\persist' + BaiduNetdisk = @{ Path = 'C:\Programs\BaiduNetdisk'; Description = '百度网盘客户端' } + # FooClolor 的具体用途不明确,先不加介绍(没有 Description 也不会影响打包) + FooClolor = 'C:\Programs\FooClolor' + March7thAssistant = @{ + Path = 'C:\Programs\March7thAssistant' + Description = '三月七助手(WebBrowser 用户目录里的缓存由 BackupList.txt 排除)' + } + MiFlash = @{ Path = 'C:\Programs\MiFlash'; Description = '小米刷机工具 MiFlash' } + MiFlash_Unlock = @{ Path = 'C:\Programs\MiFlash_Unlock'; Description = '小米解锁工具' } + QuarkCloudDrive = @{ Path = 'C:\Programs\QuarkCloudDrive'; Description = '夸克网盘客户端' } + translucenttb = @{ + Path = 'C:\Programs\ScoopApps\apps\translucenttb\current\settings.json' + Description = 'TranslucentTB 的设置文件' + } + 'ScoopApps-persist' = @{ + Path = 'C:\Programs\ScoopApps\persist' + Description = 'ScoopApps 安装位置上那份 persist。它和 scoop 数组里的 %UserProfile%\scoop\persist 是两个不同目录、末级名却同为 persist,所以不能并进同一个归档' + } # ---- 其它盘 ---- - Aria = 'D:\UserData\Documents\Aria' + Aria = @{ Path = 'D:\UserData\Documents\Aria'; Description = 'Aria 下载器的配置与任务' } } diff --git a/tests/BakNRet.Formats.Tests.ps1 b/tests/BakNRet.Formats.Tests.ps1 new file mode 100644 index 0000000..d98de2a --- /dev/null +++ b/tests/BakNRet.Formats.Tests.ps1 @@ -0,0 +1,416 @@ +<# +.SYNOPSIS + 清单"两种写法 + 追加/排除"的 Pester 测试:软件名、手写路径,:+/:- 两者都要生效。 + +.DESCRIPTION + 这里覆盖的是清单/名录的**输入格式**契约: + * 写法一:直接写 SoftwareCatalog.psd1 里的软件名; + * 写法二:用户手写目录(含 \ / 或 % 就按路径处理); + * 两种写法都要支持 `:+` 追加与 `:-` 排除; + * 名录里一个软件可以挂**对象数组**(每个目录带 Description),运行时会逐条介绍; + * 同一条目里出现两个同名目录时,必须在归档前就明确报错(Blocking), + 而不是把两棵树悄悄混在一起。 + + 跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会 + `exit`,同进程 `&` 调用会把 Pester 宿主一起带走。 + +.EXAMPLE + pwsh -File .\tests\Run-Pester.ps1 +#> + +$script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue) +$script:HasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue) + +BeforeAll { + $script:ProjectRoot = Split-Path -Parent $PSScriptRoot + $script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1' + $script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1' + $script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source + + Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force + + $script:Sandbox = Join-Path $env:TEMP ('baknret-formats-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null + + function Invoke-BaknretScript { + param( + [Parameter(Mandatory = $true)][string]$Script, + [hashtable]$Parameters = @{} + ) + + $arguments = @('-NoProfile', '-NonInteractive', '-File', $Script) + foreach ($name in ($Parameters.Keys | Sort-Object)) { + $value = $Parameters[$name] + if ($value -is [bool]) { + if ($value) { $arguments += "-$name" } + continue + } + $arguments += "-$name" + if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value } + } + + $lines = & pwsh @arguments 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Lines = @($lines | ForEach-Object { [string]$_ }) + Output = (($lines | Out-String)) + } + } + + function Write-ListFile { + param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Content) + [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) + return $Path + } +} + +AfterAll { + if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) { + Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# ============================================================================ +Describe '软件名录:对象数组写法' { +# ============================================================================ + + BeforeAll { + $script:FormatRoot = Join-Path $script:Sandbox 'format' + New-Item -ItemType Directory -Path $script:FormatRoot -Force | Out-Null + + $script:DirA = Join-Path $script:FormatRoot 'dirA' + $script:DirB = Join-Path $script:FormatRoot 'dirB' + foreach ($directory in $script:DirA, $script:DirB) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory" + } + + # 两个不同父目录下各有一个**同名**子目录 —— 用来看"归档内同名"有没有被拦住 + $script:CollideRoot = Join-Path $script:FormatRoot 'collide' + foreach ($parent in 'p1', 'p2') { + New-Item -ItemType Directory -Path (Join-Path $script:CollideRoot "$parent\dupdir") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent + } + + $dirAPath = $script:DirA + $dirBPath = $script:DirB + $collideP1 = Join-Path $script:CollideRoot 'p1\dupdir' + $collideP2 = Join-Path $script:CollideRoot 'p2\dupdir' + + $script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @" +@{ + 'pair' = @( + @{ Path = '$dirAPath'; Description = '第一个目录' } + @{ Path = '$dirBPath'; Description = '第二个目录' } + ) + 'collide' = @( + @{ Path = '$collideP1'; Description = 'p1 里的' } + @{ Path = '$collideP2'; Description = 'p2 里的' } + ) +} +"@ + + $script:MissingDir = Join-Path $script:FormatRoot 'not-here' + $missingPath = $script:MissingDir + $script:PartialCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-partial.psd1') -Content @" +@{ + 'pair' = @( + @{ Path = '$dirAPath'; Description = '存在' } + @{ Path = '$missingPath'; Description = '不存在' } + ) +} +"@ + } + + It '一个软件多个目录:顺序与说明都被保留' { + $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 + $catalog['pair'].Kind | Should -Be 'Multi' + @($catalog['pair'].Items).Count | Should -Be 2 + $catalog['pair'].Items[0].Resolved | Should -Be $script:DirA + $catalog['pair'].Items[0].Description | Should -Be '第一个目录' + $catalog['pair'].Items[1].Description | Should -Be '第二个目录' + } + + It '解析成多个源,每个源带着自己的说明' { + $entry = ConvertFrom-BackupListLine -Line 'pair' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Sources).Count | Should -Be 2 + $resolved.Sources[0].SourcePath | Should -Be $script:DirA + $resolved.Sources[0].Description | Should -Be '第一个目录' + $resolved.Sources[1].Description | Should -Be '第二个目录' + @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Be @('catalog', 'catalog') + } + + It '数组里"当前不存在"的目录仍然产出源(恢复要靠它还原回原位)' { + $entry = ConvertFrom-BackupListLine -Line 'pair' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:PartialCatalog -MaxDepth 3 + @($resolved.Sources).Count | Should -Be 2 + @($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:MissingDir + $resolved.Error | Should -Not -BeNullOrEmpty # 有提示 + $resolved.Blocking | Should -BeNullOrEmpty # 但不算致命 + } + + It '纯字符串数组写法继续可用' { + $plain = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-plain.psd1') -Content "@{ 'pair2' = @('$script:DirA', '$script:DirB') }" + $catalog = Get-SoftwareCatalog -Path $plain -MaxDepth 3 + $catalog['pair2'].Kind | Should -Be 'Multi' + @($catalog['pair2'].Dirs).Count | Should -Be 2 + } + + It '旧的 @{ Dirs = @(...) } 写法继续可用' { + $legacy = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-legacy.psd1') -Content "@{ 'pair3' = @{ Dirs = @('$script:DirA', '$script:DirB') } }" + $catalog = Get-SoftwareCatalog -Path $legacy -MaxDepth 3 + $catalog['pair3'].Kind | Should -Be 'Multi' + @($catalog['pair3'].Dirs).Count | Should -Be 2 + } + + It '数组形式的名录条目在清单里仍然按软件名命名归档' { + $entry = ConvertFrom-BackupListLine -Line 'pair' + (Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair' + } +} + +# ============================================================================ +Describe '两种写法都要支持 :+ 追加与 :- 排除' { +# ============================================================================ + + BeforeAll { + $script:FormatRoot = Join-Path $script:Sandbox 'format' + $script:DirA = Join-Path $script:FormatRoot 'dirA' + $script:DirB = Join-Path $script:FormatRoot 'dirB' + $script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1' + $script:CollideRoot = Join-Path $script:FormatRoot 'collide' + } + + It '软件名写法::+ 追加一个目录' { + $entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Sources).Count | Should -Be 3 + @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path' + } + + It '软件名写法::+ 追加"另一个软件名"会按名录展开成它的全部目录' { + $entry = ConvertFrom-BackupListLine -Line 'pair :+ pair' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Sources).Count | Should -Be 4 + @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-catalog' + } + + # ---- 回归:手写路径的 :+ 以前会被整段丢掉 ---- + It '[回归] 手写路径写法::+ 追加一个目录' { + $entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Sources).Count | Should -Be 2 + @($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:DirB + @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path' + } + + It '手写路径写法::- 排除与 :+ 追加并存' { + $entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB :- skip.log,!*Cache" + $entry.ExcludePatterns.Count | Should -Be 2 + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Sources).Count | Should -Be 2 + $resolved.Blocking | Should -BeNullOrEmpty + } + + It '软件名与手写路径混在一行也认得(主目录是软件名,追加是路径)' { + $entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot :- logs\" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + $resolved.IsName | Should -BeTrue + @($resolved.Sources).Count | Should -Be 3 + $entry.ExcludePatterns | Should -Be @('logs\') + } + + It '同一条目里出现两个同名目录 -> Blocking(明确报错,不静默混成一棵树)' { + $entry = ConvertFrom-BackupListLine -Line 'collide' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Sources).Count | Should -Be 2 + $resolved.Blocking | Should -Match '顶层同名' + } +} + +# ============================================================================ +Describe '清单行尾的 `# 说明`' { +# ============================================================================ + + It '会作为这条目的说明解析出来' { + $entry = ConvertFrom-BackupListLine -Line 'Edge :- !*Cache # 缓存可再生' + $entry.Path | Should -Be 'Edge' + $entry.ExcludePatterns | Should -Be @('!*Cache') + $entry.Comment | Should -Be '缓存可再生' + } + + It '路径里紧贴的 # 不会被当成注释' { + $entry = ConvertFrom-BackupListLine -Line 'C:\a#b\c' + $entry.Path | Should -Be 'C:\a#b\c' + $entry.Comment | Should -BeNullOrEmpty + } + + It '没有说明时 Comment 为空' { + ConvertFrom-BackupListLine -Line 'legendary' | Select-Object -ExpandProperty Comment | Should -BeNullOrEmpty + } +} + +# ============================================================================ +Describe '集成:手写路径 + :+ 追加 的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +# ============================================================================ + + BeforeAll { + $script:AppendRoot = Join-Path $script:Sandbox 'append-e2e' + # 刻意放在**两个不同的父目录**下:只有这样才能验证 + # "恢复时不会把兄弟目录也复制过去" + $script:AppendA = Join-Path $script:AppendRoot 'srcA\dirA' + $script:AppendB = Join-Path $script:AppendRoot 'srcB\dirB' + foreach ($directory in $script:AppendA, $script:AppendB) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + Set-Content -LiteralPath (Join-Path $script:AppendA 'a.txt') 'A' + Set-Content -LiteralPath (Join-Path $script:AppendB 'b.txt') 'B' + Set-Content -LiteralPath (Join-Path $script:AppendA 'skip.log') 'S' + + $script:AppendList = Write-ListFile -Path (Join-Path $script:AppendRoot 'list.txt') ` + -Content "$script:AppendA :+ $script:AppendB :- skip.log`n" + $script:AppendBackupDir = Join-Path $script:AppendRoot 'Backups' + + $script:AppendBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:AppendList + BackupDir = $script:AppendBackupDir + Force = $true + QuietTool = $true + } + } + + It '备份成功,manifest.roots 记录两棵子树' { + $script:AppendBackupRun.ExitCode | Should -Be 0 + $archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0] + $record = (Read-BaknretManifest -Path (Join-Path $script:AppendBackupDir 'manifest.json')).items[$archive.BaseName] + $record.roots | Should -Contain 'dirA' + $record.roots | Should -Contain 'dirB' + } + + It '归档里两棵树都在,且 :- 排除生效' { + $verify = Join-Path $script:AppendRoot 'verify' + New-Item -ItemType Directory -Path $verify -Force | Out-Null + $archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0] + (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.FullName)) | Should -Be 0 + + Test-Path -LiteralPath (Join-Path $verify 'dirA\a.txt') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $verify 'dirB\b.txt') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $verify 'dirA\skip.log') | Should -BeFalse + } + + It '删源后恢复:每个目录只落回自己的父目录,兄弟目录不会被复制过去' { + Remove-Item -LiteralPath $script:AppendA -Recurse -Force + Remove-Item -LiteralPath $script:AppendB -Recurse -Force + + $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ + BackupListPath = $script:AppendList + BackupDir = $script:AppendBackupDir + Force = $true + } + $run.ExitCode | Should -Be 0 + + Test-Path -LiteralPath (Join-Path $script:AppendA 'a.txt') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $script:AppendB 'b.txt') | Should -BeTrue + + # 关键:srcA 下不该冒出 dirB,srcB 下也不该冒出 dirA + Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcA\dirB') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcB\dirA') | Should -BeFalse + } +} + +# ============================================================================ +Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +# ============================================================================ + + BeforeAll { + $script:RejectRoot = Join-Path $script:Sandbox 'collide-e2e' + $script:RejectCatalog = Join-Path $script:RejectRoot 'cat.psd1' + $p1 = Join-Path $script:RejectRoot 'p1\dupdir' + $p2 = Join-Path $script:RejectRoot 'p2\dupdir' + foreach ($directory in $p1, $p2) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $directory 'x.txt') 'x' + } + Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @('$p1', '$p2')`n}`n" | Out-Null + $script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide`n" + $script:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n" + $script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups' + + $script:RejectRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:RejectList + BackupDir = $script:RejectBackupDir + ConfigPath = $script:RejectConfig + Force = $true + QuietTool = $true + } + } + + It '退出码 1,且给出"顶层同名"的原因,不生成归档' { + $script:RejectRun.ExitCode | Should -Be 1 + $script:RejectRun.Output | Should -Match '顶层同名' + @(Get-ChildItem -LiteralPath $script:RejectBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue).Count | Should -Be 0 + } + + It 'manifest 里记下这次是 failed,并带上原因' { + $manifest = Read-BaknretManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json') + $record = $manifest.items['collide'] + $record.action | Should -Be 'failed' + $record.reason | Should -Match '顶层同名' + } +} + +# ============================================================================ +Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +# ============================================================================ + + # 这是"合并/改名条目"的真实后果:归档还在,manifest 里也还留着历史记录, + # 但清单里已经没有任何条目指向它 —— Restore.ps1 按条目名找归档,所以它恢复不到。 + # 审计如果拿 manifest 当"已知",这种归档会被历史记录永远遮住,正是最该报的情况。 + + BeforeAll { + $script:OrphanRoot = Join-Path $script:Sandbox 'orphan-e2e' + $script:OrphanSource = Join-Path $script:OrphanRoot 'src\My App' + New-Item -ItemType Directory -Path $script:OrphanSource -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello' + + $script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') ` + -Content "@{`n 'my-app' = '$script:OrphanSource'`n}`n" + $script:OrphanConfig = Write-ListFile -Path (Join-Path $script:OrphanRoot 'config.psd1') ` + -Content "@{ SoftwareCatalog = '$script:OrphanCatalog' }`n" + $script:OrphanList = Join-Path $script:OrphanRoot 'list.txt' + $script:OrphanBackupDir = Join-Path $script:OrphanRoot 'Backups' + + # 第一次:清单里有 my-app + Write-ListFile -Path $script:OrphanList -Content "my-app`n" | Out-Null + $null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:OrphanList + BackupDir = $script:OrphanBackupDir + ConfigPath = $script:OrphanConfig + Force = $true + QuietTool = $true + } + + # 第二次:把条目从清单里拿掉,归档留在磁盘上 + Write-ListFile -Path $script:OrphanList -Content "# 清单里没有条目了`n" | Out-Null + $script:OrphanRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:OrphanList + BackupDir = $script:OrphanBackupDir + ConfigPath = $script:OrphanConfig + QuietTool = $true + } + } + + It '归档确实还在磁盘上,manifest 里也还留着历史记录' { + Test-Path -LiteralPath (Join-Path $script:OrphanBackupDir 'my-app.7z') | Should -BeTrue + $manifest = Read-BaknretManifest -Path (Join-Path $script:OrphanBackupDir 'manifest.json') + $manifest.items.Contains('my-app') | Should -BeTrue + } + + It '第二次运行把 my-app.7z 点名成孤儿,并说明 manifest 里还有历史记录' { + $script:OrphanRun.ExitCode | Should -Be 0 + $script:OrphanRun.Output | Should -Match '孤儿归档' + $script:OrphanRun.Output | Should -Match 'my-app\.7z' + $script:OrphanRun.Output | Should -Match 'manifest 里还留着它的历史记录' + } +} diff --git a/tests/Restore-Drill.ps1 b/tests/Restore-Drill.ps1 index dcf2fac..2e588c4 100644 --- a/tests/Restore-Drill.ps1 +++ b/tests/Restore-Drill.ps1 @@ -211,29 +211,40 @@ foreach ($name in $Entries) { $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' } continue } - if ($sources.Count -gt 1) { - # 多目录条目恢复时会整包解压到每个位置,逐个对拍意义不大,默认不演练 - $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多目录条目($($sources.Count) 个源),不在演练范围内" } + + if (@($sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }).Count -eq 0) { + $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' } continue } - $liveSource = $sources[0].SourcePath - if (-not (Test-Path -LiteralPath $liveSource)) { - $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "活源不存在,无法对拍:$liveSource" } - continue + $entryRoot = Join-Path (Join-Path $WorkRoot 'restore') $name + New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null + + # 每个源各自映射到一个临时目标:临时名录保持**同样的个数与顺序**, + # 于是 Restore 会把第 i 个源还原到第 i 个临时目录,再和第 i 个活源逐字节对拍。 + # (一个条目可以挂多个目录:软件名录的数组写法、以及清单里的 :+ 追加。) + $scratchEntries = @() + $pairs = @() + for ($index = 0; $index -lt $sources.Count; $index++) { + $source = $sources[$index] + $leaf = @($source.RelativePaths)[0] + $scratchTarget = Join-Path (Join-Path $entryRoot $index) $leaf + $scratchEntries += $scratchTarget + $pairs += [pscustomobject]@{ + Restored = $scratchTarget + Live = $source.SourcePath + Exists = (Test-Path -LiteralPath $source.SourcePath) + } } - $leaf = Split-Path -Path $liveSource -Leaf - $restoreParent = Join-Path (Join-Path $WorkRoot 'restore') $name - $scratchTarget = Join-Path $restoreParent $leaf - New-Item -ItemType Directory -Path $restoreParent -Force | Out-Null - - # 临时名录:把这个软件名指到临时目标,Restore 就会解到这里,碰不到真实目录 + # 临时名录:把这些目录全指到临时目标,Restore 就解到这里,碰不到真实目录 $scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1") $scratchList = Join-Path $WorkRoot ("list-$name.txt") $scratchConfig = Join-Path $WorkRoot ("config-$name.psd1") - [System.IO.File]::WriteAllText($scratchCatalog, "@{`n '$name' = '$scratchTarget'`n}`n", [System.Text.UTF8Encoding]::new($false)) + $itemLines = @($scratchEntries | ForEach-Object { " @{ Path = '$_' }" }) -join "`n" + [System.IO.File]::WriteAllText($scratchCatalog, + "@{`n '$name' = @(`n$itemLines`n )`n}`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($scratchList, "$name`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($scratchConfig, @" @{ @@ -245,7 +256,7 @@ foreach ($name in $Entries) { } "@, [System.Text.UTF8Encoding]::new($false)) - Write-Host ("-- 演练 {0}(归档 {1}.7z)" -f $name, $resolved.BaseName) -ForegroundColor Gray + Write-Host ("-- 演练 {0}(归档 {1}.7z,{2} 个目录)" -f $name, $resolved.BaseName, $sources.Count) -ForegroundColor Gray # 用**子进程**跑 Restore.ps1:它结尾会 exit,子进程既不会打断演练, # 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。 @@ -270,37 +281,54 @@ foreach ($name in $Entries) { } $checked++ - $report = Compare-RestoredTree -RestoredPath $scratchTarget -LivePath $liveSource -ArchiveTime $archiveTime + $matched = 0 + $restoredCount = 0 + $extra = @() + $stale = @() + $changed = @() + $notArchived = @() - $detail = "对拍 $($report.Matched)/$($report.Restored)" + foreach ($pair in $pairs) { + # 活源本来就没了的不对拍(归档里也不该有它) + if (-not $pair.Exists) { continue } + $one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime + $matched += $one.Matched + $restoredCount += $one.Restored + $extra += $one.Extra + $stale += $one.Stale + $changed += $one.Changed + $notArchived += $one.Missing + } + + $detail = "对拍 $matched/$restoredCount" $status = 'PASS' - if ($report.Extra.Count -gt 0) { + if ($extra.Count -gt 0) { $status = 'FAIL' - $detail += ";归档里有源里没有的 $($report.Extra.Count) 个文件" - $failures += "$name :恢复出源里没有的文件(首例 $($report.Extra[0]))" + $detail += ";归档里有源里没有的 $($extra.Count) 个文件" + $failures += "$name :恢复出源里没有的文件(首例 $($extra[0]))" } - if ($report.Stale.Count -gt 0) { + if ($stale.Count -gt 0) { # 活源在归档之后被改过:源变了,不是归档坏了,只提示 - $detail += ";源在备份后变过 $($report.Stale.Count) 个(不算失败)" + $detail += ";源在备份后变过 $($stale.Count) 个(不算失败)" } - if ($report.Changed.Count -gt 0) { + if ($changed.Count -gt 0) { if ($AllowChanged) { - $detail += ";与活源不一致 $($report.Changed.Count) 个(-AllowChanged,已容忍)" + $detail += ";与活源不一致 $($changed.Count) 个(-AllowChanged,已容忍)" } else { $status = 'FAIL' - $detail += ";与活源不一致 $($report.Changed.Count) 个(首例 $($report.Changed[0]))" - $failures += "$name :与活源不一致(首例 $($report.Changed[0]))" + $detail += ";与活源不一致 $($changed.Count) 个(首例 $($changed[0]))" + $failures += "$name :与活源不一致(首例 $($changed[0]))" } } - if (($report.Matched + $report.Stale.Count) -eq 0) { + if (($matched + $stale.Count) -eq 0) { $status = 'FAIL' $detail += ";没有任何文件能对上(多半是空归档)" $failures += "$name :恢复出 0 个可对拍的文件" } - if ($report.Missing.Count -gt 0) { + if ($notArchived.Count -gt 0) { # 排除规则命中的文件、以及备份之后新增的文件都会落在这里,只是提示 - $detail += ";活源另有 $($report.Missing.Count) 个文件不在归档里(排除规则/备份后新增)" + $detail += ";活源另有 $($notArchived.Count) 个文件不在归档里(排除规则/备份后新增)" } $rows += [pscustomobject]@{ Entry = $name; Status = $status; Detail = $detail } diff --git a/tests/Run-Pester.ps1 b/tests/Run-Pester.ps1 index 4ec822f..2a9fb50 100644 --- a/tests/Run-Pester.ps1 +++ b/tests/Run-Pester.ps1 @@ -26,7 +26,8 @@ param( [string[]]$Tag, - [string]$TestPath = (Join-Path $PSScriptRoot 'BakNRet.Tests.ps1') + # 默认跑 tests/ 下所有 *.Tests.ps1(Pester 按这个命名约定发现) + [string]$TestPath = $PSScriptRoot ) $ErrorActionPreference = 'Stop' diff --git a/tools/Rename-Archives.ps1 b/tools/Rename-Archives.ps1 index 01462e6..3be67ba 100644 --- a/tools/Rename-Archives.ps1 +++ b/tools/Rename-Archives.ps1 @@ -94,6 +94,11 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) { $oldNameSource = $item.Path if ($resolved.IsName -and $resolved.CatalogEntry) { $oldNameSource = $resolved.CatalogEntry.Path } + if ([string]::IsNullOrWhiteSpace([string]$oldNameSource)) { + # 数组形式的名录条目没有唯一的"原路径",推不出旧归档名,跳过即可 + Write-Host (" 跳过 {0}:名录条目是数组形式,算不出旧归档名" -f $item.Path) -ForegroundColor DarkGray + continue + } $oldName = Get-BackupBaseName -RawPath $oldNameSource if (-not $oldName -or -not $newName) { continue }