清单格式:两种写法(软件名 / 手写目录)都支持 :+ 追加与 :- 排除;名录改用对象数组并逐条介绍目录
需求
- 支持两种条目写法:1) 直接写软件名录里的软件名;2) 用户手写目录。
- 两种写法都必须支持追加(:+)与排除(:-)。
- 软件名录要改进:scoop 合并成"一个软件 + 一个目录数组"。
- 运行时要把"分别是哪些目录、每个目录是干什么的、排除/追加的理由"讲清楚。
实现
- 名录(SoftwareCatalog.psd1 / Get-SoftwareCatalog)
* 一个软件挂多个目录时写成**对象数组**:@{ Path = '...'; Description = '...' };
也接受纯字符串数组与旧的 @{ Dirs = ... } / @{ Variants = ... }。
* 目录说明(Description)一路带到运行日志里。
* "声明了但当前不存在"的目录不再被丢掉:备份跳过,恢复仍然知道它该回到哪个位置。
* scoop 合并成一个数组条目(%UserProfile%\scoop\persist + %UserProfile%\.config\scoop);
ScoopApps-persist 保持独立条目 —— 它和前者末级名同为 persist,并进同一个归档会在包里撞名。
- 解析与解析结果(ConvertFrom-BackupListLine / Resolve-BackupEntry)
* `:+` 以前只对"软件名且能解析出目录"的写法生效,**手写目录的 :+ 会被整段丢掉**;
现在统一生效,且 :+ 后面写软件名会按名录展开。
* 行尾 `# 说明` 解析成 Comment,运行时打印。
- 归档与恢复
* 同一条目里两个同名目录:打包前明确报错(退出码 1),不再静默混成一棵树。
(7z 命令行没有"入库改名"的能力,归档内顶层名只能是文件系统上的那个名字。)
* 恢复时每个源只解出**它自己那棵子树**,不会再往别的父目录里复制兄弟目录。
- 可解释性
* 新增 Write-BackupEntryPlan:打包前打印条目的目录(含来源与介绍)以及排除/追加的出处;
Restore.ps1 同样打印"哪棵子树还原到哪、会新建还是覆盖"。
- 孤儿归档审计修正:判据只看当前清单,不再把 manifest 的历史记录当成"已知"。
否则"条目被合并/改名后留下的旧归档"会被历史记录遮住,永远不会报警。
验证
- Pester 84 项、零依赖单元 49 项、端到端 23 项,全部通过。
- 真实机器:合并后的 scoop.7z 233 MB / 29478 项 / 7z t 通过,manifest.roots=[persist|scoop];
恢复演练 26982/26982 逐字节一致(.ssh、legendary、Aria 同批通过)。
- 迁移提醒:scoop-config.7z 与 scoop-persist.7z 已无清单条目指向,会出现在孤儿审计里;
确认 scoop.7z 无误后可以自行删除。
This commit is contained in:
1 parent
e114cae8c8
commit
43fa4e52dd
10 files changed
+1065
-321
No files matched your search
+29
-12
@@ -444,6 +444,19 @@ foreach ($line in $lines) {
|
|||||||
}
|
}
|
||||||
$seenBaseNames[$baseName] = $displayPath
|
$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 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。
|
# Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。
|
||||||
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
|
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
|
||||||
# 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的
|
# 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的
|
||||||
@@ -459,14 +472,13 @@ foreach ($line in $lines) {
|
|||||||
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前:
|
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前:
|
||||||
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
|
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
|
||||||
# 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。
|
# 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。
|
||||||
|
# 源路径存在性以 SourcePath 为准:RelativePaths 是"归档里的名字",
|
||||||
|
# 目前两者一致,但 SourcePath 才是磁盘上的真实位置。
|
||||||
$expectedRoots = 0
|
$expectedRoots = 0
|
||||||
$missingRoots = @()
|
$missingRoots = @()
|
||||||
foreach ($source in $resolved.Sources) {
|
foreach ($source in $resolved.Sources) {
|
||||||
foreach ($relative in $source.RelativePaths) {
|
|
||||||
$expectedRoots++
|
$expectedRoots++
|
||||||
$candidate = "$($source.ParentDir.TrimEnd('\'))\$relative"
|
if (-not (Test-Path -LiteralPath $source.SourcePath)) { $missingRoots += $source.SourcePath }
|
||||||
if (-not (Test-Path -LiteralPath $candidate)) { $missingRoots += $candidate }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($missingRoots.Count -ge $expectedRoots) {
|
if ($missingRoots.Count -ge $expectedRoots) {
|
||||||
@@ -483,13 +495,14 @@ foreach ($line in $lines) {
|
|||||||
# 归档里只放真实存在的源
|
# 归档里只放真实存在的源
|
||||||
$liveSources = @()
|
$liveSources = @()
|
||||||
foreach ($source in $resolved.Sources) {
|
foreach ($source in $resolved.Sources) {
|
||||||
$live = @($source.RelativePaths | Where-Object { Test-Path -LiteralPath "$($source.ParentDir.TrimEnd('\'))\$_" })
|
if (Test-Path -LiteralPath $source.SourcePath) {
|
||||||
if ($live.Count -gt 0) {
|
|
||||||
$liveSources += [pscustomobject]@{
|
$liveSources += [pscustomobject]@{
|
||||||
RootName = $source.RootName
|
RootName = $source.RootName
|
||||||
ParentDir = $source.ParentDir
|
ParentDir = $source.ParentDir
|
||||||
RelativePaths = $live
|
RelativePaths = @($source.RelativePaths)
|
||||||
SourcePath = $source.SourcePath
|
SourcePath = $source.SourcePath
|
||||||
|
Description = $source.Description
|
||||||
|
Origin = $source.Origin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,23 +666,27 @@ if ($DryRun) {
|
|||||||
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
|
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
|
||||||
}
|
}
|
||||||
|
|
||||||
# 孤儿归档审计:磁盘上有、但清单里任何条目都不指向的归档。
|
# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。
|
||||||
# Restore.ps1 只能按条目名找归档,所以孤儿是**恢复不到**的 —— 必须显式点名,
|
# Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名,
|
||||||
# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。
|
# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。
|
||||||
|
#
|
||||||
|
# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目,
|
||||||
|
# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住,
|
||||||
|
# 审计就永远不会报——那正是最需要报出来的情况。
|
||||||
# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里,
|
# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里,
|
||||||
# 那种情况下报出来的全是假孤儿。
|
# 那种情况下报出来的全是假孤儿。
|
||||||
if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
|
if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
|
||||||
$known = @{}
|
$known = @{}
|
||||||
foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true }
|
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 |
|
$orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||||
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) })
|
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) })
|
||||||
|
|
||||||
if ($orphanArchives.Count -gt 0) {
|
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) {
|
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 {
|
} else {
|
||||||
Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG
|
Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG
|
||||||
|
|||||||
+29
-16
@@ -1,27 +1,39 @@
|
|||||||
# BackupList.txt —— 备份 / 恢复共用清单
|
# BackupList.txt —— 备份 / 恢复共用清单
|
||||||
#
|
#
|
||||||
# 语法:
|
# 每一行支持**两种写法**,混用没问题:
|
||||||
# <软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
|
||||||
#
|
#
|
||||||
# 三种写法都可以,混用也没问题:
|
# 1. 软件名(推荐)—— 去 SoftwareCatalog.psd1 查目录,归档名就是软件名
|
||||||
#
|
|
||||||
# 1. 软件名(推荐)—— 去 SoftwareCatalog.psd1 里查目录,归档名就是软件名
|
|
||||||
# FooClolor
|
# FooClolor
|
||||||
# Kazumi :: !*Cache
|
# scoop
|
||||||
|
# Kazumi :- !*Cache
|
||||||
#
|
#
|
||||||
# 2. 字面路径 —— 含 `\`、`/` 或 `%` 就按路径处理,归档名沿用 <名>_from_<路径>
|
# 2. 用户手写的目录 —— 含 `\`、`/` 或 `%` 就按路径处理,归档名沿用 <名>_from_<路径>
|
||||||
# %UserProfile%\Documents\PowerShell
|
# %UserProfile%\Documents\PowerShell
|
||||||
# C:\Programs\MiFlash
|
# C:\Programs\MiFlash :- MiFlash\logs\
|
||||||
#
|
#
|
||||||
# 3. 软件名 + @pathname —— 强制用旧的路径命名算法
|
# 3. 软件名 + @pathname —— 强制用旧的路径命名算法(归档名从路径算)
|
||||||
# FooClolor @pathname
|
# FooClolor @pathname
|
||||||
#
|
#
|
||||||
|
# 两种写法都支持**追加**与**排除**:
|
||||||
|
#
|
||||||
|
# :+ 追加一个目录;写成软件名时会按名录展开成它的全部目录
|
||||||
|
# %UserProfile%\Documents\PowerShell :+ D:\backup\ps-extra
|
||||||
|
# MiFlash :+ MiFlash_Unlock
|
||||||
|
# :+ 可以出现多次、位置随意;追加进来的目录与主目录一起打进同一个归档。
|
||||||
|
#
|
||||||
|
# :- 排除模式(`::` 是它的历史别名,两者等价)
|
||||||
|
# Edge :- !*Cache,Default\Extensions
|
||||||
|
# `,` 与 `;` 都当分隔符。
|
||||||
|
#
|
||||||
|
# 行尾可以写 `# 说明` 讲清这条为什么这么配;运行时会把它和目录介绍一起打印出来:
|
||||||
|
# Edge :- !*Cache # 缓存可再生,不进归档
|
||||||
|
#
|
||||||
# 排除模式:相对归档根目录。以 ! 开头表示"任意层级下匹配这个组件名"(7z 的 -xr!)。
|
# 排除模式:相对归档根目录。以 ! 开头表示"任意层级下匹配这个组件名"(7z 的 -xr!)。
|
||||||
# 不要自己写引号;模式里的空格会被自动转成 ?(7z 的模式不支持空格)。
|
# 不要自己写引号;模式里的空格会被自动转成 ?(7z 的模式不支持空格)。
|
||||||
# 标记:
|
# 标记:
|
||||||
# encrypt 用 7z 加密该归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile)
|
# encrypt 用 7z 加密该归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile)
|
||||||
# pathname 用路径命名算法而不是软件名
|
# pathname 用路径命名算法而不是软件名
|
||||||
# root=<名> 覆盖归档内的根目录名(默认就是软件名)
|
# root=<名> 尚未实现(归档内根目录始终是源目录名),用了会告警
|
||||||
#
|
#
|
||||||
# 归档名 = 软件名,所以:**同一个软件不要写两遍**,脚本会直接报重复错误。
|
# 归档名 = 软件名,所以:**同一个软件不要写两遍**,脚本会直接报重复错误。
|
||||||
# 软件名(连同排除规则、加密标记)都维护在 SoftwareCatalog.psd1 和本文件里,
|
# 软件名(连同排除规则、加密标记)都维护在 SoftwareCatalog.psd1 和本文件里,
|
||||||
@@ -30,13 +42,14 @@
|
|||||||
# ---- 用户配置 / 开发环境 ----
|
# ---- 用户配置 / 开发环境 ----
|
||||||
legendary
|
legendary
|
||||||
opencode
|
opencode
|
||||||
scoop-config
|
# scoop 是一个软件名 + 对象数组(见 SoftwareCatalog.psd1):一个 scoop.7z 里
|
||||||
|
# 同时装 %UserProfile%\scoop\persist 与 %UserProfile%\.config\scoop。
|
||||||
|
scoop # scoop 各应用的持久化数据 + scoop 自身配置
|
||||||
# .ssh 里是私钥。想加密就把下面那行 @encrypt 的注释互换(见 README「加密」)
|
# .ssh 里是私钥。想加密就把下面那行 @encrypt 的注释互换(见 README「加密」)
|
||||||
.ssh
|
.ssh
|
||||||
CodeSpace :: Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\
|
CodeSpace :- Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\ # 排除同一仓库里的源码树
|
||||||
PowerShell
|
PowerShell
|
||||||
WindowsPowerShell
|
WindowsPowerShell
|
||||||
scoop-persist
|
|
||||||
|
|
||||||
# ---- 应用数据 ----
|
# ---- 应用数据 ----
|
||||||
AutoDarkMode
|
AutoDarkMode
|
||||||
@@ -63,7 +76,7 @@ twinkle-tray
|
|||||||
#
|
#
|
||||||
# 注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies),
|
# 注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies),
|
||||||
# 脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 Edge。
|
# 脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 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
|
WindowsTerminal
|
||||||
|
|
||||||
# ---- 系统 ----
|
# ---- 系统 ----
|
||||||
@@ -72,12 +85,12 @@ Startup
|
|||||||
# ---- C:\Programs ----
|
# ---- C:\Programs ----
|
||||||
BaiduNetdisk
|
BaiduNetdisk
|
||||||
FooClolor
|
FooClolor
|
||||||
March7thAssistant :: 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\
|
March7thAssistant :- 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\ # 内置浏览器的缓存与日志,可再生
|
||||||
MiFlash
|
MiFlash
|
||||||
MiFlash_Unlock
|
MiFlash_Unlock
|
||||||
QuarkCloudDrive
|
QuarkCloudDrive
|
||||||
translucenttb
|
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
|
Aria
|
||||||
+289
-200
@@ -392,6 +392,17 @@ function ConvertFrom-BackupListLine {
|
|||||||
return $null
|
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 路径里只可能是盘符,
|
# 行内记号(都用到 `:`,因为 `:` 在 Windows 路径里只可能是盘符,
|
||||||
# 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响):
|
# 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响):
|
||||||
# :: 排除模式(历史写法,等价于 :-)
|
# :: 排除模式(历史写法,等价于 :-)
|
||||||
@@ -477,6 +488,7 @@ function ConvertFrom-BackupListLine {
|
|||||||
AddedPaths = $addedPaths
|
AddedPaths = $addedPaths
|
||||||
ExcludePatterns = $excludes
|
ExcludePatterns = $excludes
|
||||||
Flags = $flags
|
Flags = $flags
|
||||||
|
Comment = $comment
|
||||||
Raw = $Line
|
Raw = $Line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,105 +668,123 @@ function Get-SoftwareCatalog {
|
|||||||
|
|
||||||
$entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key }
|
$entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key }
|
||||||
|
|
||||||
|
# 一个软件可以对应**多个目录**,写成数组;数组元素两种都认:
|
||||||
|
# * 对象(推荐):@{ Path = '<目录>'; Description = '<这个目录是干什么的>' }
|
||||||
|
# * 纯字符串: '<目录>'
|
||||||
|
# 也兼容字典写法:@{ Dirs = @(...) } / @{ Variants = @(...) } / @{ Path = '<目录>' }
|
||||||
$rawPath = $null
|
$rawPath = $null
|
||||||
$dirList = @()
|
$candidates = @()
|
||||||
|
$entryDescription = $null
|
||||||
|
|
||||||
if ($entry -is [System.Collections.IDictionary]) {
|
if ($entry -is [System.Collections.IDictionary]) {
|
||||||
# Dirs = 一个软件包含的多个目录(推荐写法)
|
foreach ($key in 'Description', 'Note', 'Desc') {
|
||||||
# Variants = 同名目录出现在多个位置(旧写法,等价于 Dirs,保留兼容)
|
if ($entry.Contains($key)) { $entryDescription = [string]$entry[$key]; break }
|
||||||
if ($entry.Contains('Dirs')) { $dirList = @($entry['Dirs']) }
|
}
|
||||||
elseif ($entry.Contains('Variants')) { $dirList = @($entry['Variants']) }
|
if ($entry.Contains('Dirs')) { $candidates = @($entry['Dirs']) }
|
||||||
|
elseif ($entry.Contains('Variants')) { $candidates = @($entry['Variants']) }
|
||||||
if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] }
|
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
|
$rawPath = [string]$entry
|
||||||
}
|
}
|
||||||
if (-not $rawPath -and $dirList.Count -eq 0) { continue }
|
|
||||||
|
|
||||||
$resolved = if ($rawPath) { [Environment]::ExpandEnvironmentVariables($rawPath) } else { $null }
|
# 单目录写法:包成对象,好让"目录说明"跟目录一起走下去
|
||||||
|
if ($candidates.Count -eq 0 -and $rawPath) {
|
||||||
|
$candidates = @([pscustomobject]@{ Path = $rawPath; Description = $entryDescription })
|
||||||
|
}
|
||||||
|
if ($candidates.Count -eq 0) { continue }
|
||||||
|
|
||||||
# 多目录:逐个解析(每个都可以用前缀补全),任一解析不出来就整体 Unresolved
|
# 逐个候选目录解析。**声明了几个就记几个**,找不到的也留着:
|
||||||
if ($dirList.Count -gt 0) {
|
# 备份端按存在性跳过它们,恢复端要靠它们把内容还原回原位。
|
||||||
$paths = @()
|
$items = @()
|
||||||
$unresolved = @()
|
foreach ($candidate in $candidates) {
|
||||||
foreach ($item in $dirList) {
|
$candidatePath = $null
|
||||||
$candidate = [Environment]::ExpandEnvironmentVariables([string]$item)
|
$candidateDescription = $null
|
||||||
if (Test-Path -LiteralPath $candidate) {
|
|
||||||
$paths += $candidate
|
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
|
continue
|
||||||
}
|
}
|
||||||
$parent = Split-Path -Path $candidate -Parent
|
|
||||||
$leaf = Split-Path -Path $candidate -Leaf
|
# 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 <name>_2.0.4)
|
||||||
|
$parent = Split-Path -Path $declared -Parent
|
||||||
|
$leaf = Split-Path -Path $declared -Leaf
|
||||||
$found = $null
|
$found = $null
|
||||||
if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) {
|
if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) {
|
||||||
$found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth)
|
$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 (Test-Path -LiteralPath $resolved) {
|
|
||||||
$kind = 'Single'
|
|
||||||
} else {
|
|
||||||
# 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 <name>_2.0.4)
|
|
||||||
$parent = Split-Path -Path $resolved -Parent
|
|
||||||
$leaf = Split-Path -Path $resolved -Leaf
|
|
||||||
$found = $null
|
|
||||||
if ($parent -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) {
|
if ($found -and $found.Count -gt 0) {
|
||||||
$resolved = $found[0]
|
foreach ($match in $found) {
|
||||||
$result[$name] = [pscustomobject]@{
|
$items += [pscustomobject]@{ Declared = $declared; Resolved = $match; Exists = $true; Suffixed = $true; Description = $candidateDescription }
|
||||||
Name = $name
|
|
||||||
Path = $rawPath
|
|
||||||
ResolvedPath = $resolved
|
|
||||||
Variants = @($found)
|
|
||||||
Dirs = @($found)
|
|
||||||
Missing = @()
|
|
||||||
Kind = $kind
|
|
||||||
Raw = $entry
|
|
||||||
Suffixed = $true
|
|
||||||
}
|
}
|
||||||
Write-Log "名录:$name -> $resolved(按前缀补全)" -Level DEBUG
|
Write-Log "名录:$name 的 $declared -> $($found -join '、')(按前缀补全)" -Level DEBUG
|
||||||
continue
|
} 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]@{
|
$result[$name] = [pscustomobject]@{
|
||||||
Name = $name
|
Name = $name
|
||||||
Path = $rawPath
|
# Path 保留"名录里写的那个字符串"。数组写法没有唯一字符串,取第一个候选项。
|
||||||
ResolvedPath = $resolved
|
Path = $(if ($rawPath) { $rawPath } else { $declaredList[0] })
|
||||||
Variants = @()
|
ResolvedPath = $(if ($existing.Count -gt 0) { $existing[0] } else { $missing[0] })
|
||||||
Dirs = $(if ($kind -eq 'Single') { @($resolved) } else { @() })
|
Variants = $existing
|
||||||
Missing = @()
|
Dirs = $existing
|
||||||
|
Declared = $declaredList
|
||||||
|
Items = $items
|
||||||
|
Missing = $missing
|
||||||
|
Description = $entryDescription
|
||||||
Kind = $kind
|
Kind = $kind
|
||||||
Raw = $entry
|
Raw = $entry
|
||||||
|
Suffixed = [bool](@($items | Where-Object { $_.Suffixed }).Count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +909,46 @@ function Get-ItemArchiveName {
|
|||||||
return Get-BackupBaseName -RawPath $Entry.Path
|
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 {
|
function Resolve-BackupEntry {
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
@@ -910,158 +980,177 @@ function Resolve-BackupEntry {
|
|||||||
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
|
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
|
||||||
$rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) })
|
$rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) })
|
||||||
|
|
||||||
# 必须在下面任何一个 return 之前算出来:名录里没有这个名字时也要走
|
# 必须在下面任何一个分支之前算出来:名录里没有这个名字时也要用它,
|
||||||
# "路径不存在" 分支,那条分支引用 $rootName。原先它写在后面,靠 PowerShell
|
# 否则会读到调用方作用域里残留的 $rootName(PowerShell 是动态作用域)。
|
||||||
# 的隐式 $null 侥幸不报错,但会把**外层作用域**残留的 $rootName 带进来。
|
|
||||||
$rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName }
|
$rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName }
|
||||||
|
|
||||||
if (-not $isName) {
|
$catalogEntry = $null
|
||||||
$sourcePath = [Environment]::ExpandEnvironmentVariables($Entry.Path)
|
$sources = @()
|
||||||
return [pscustomobject]@{
|
$archiveFlavor = 'name'
|
||||||
IsName = $false
|
$errorText = $null
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
|
||||||
if (-not $catalog.ContainsKey($Entry.Path)) {
|
if (-not $catalog.ContainsKey($Entry.Path)) {
|
||||||
return [pscustomobject]@{
|
$errorText = "软件名录里没有 '$($Entry.Path)'"
|
||||||
IsName = $true
|
|
||||||
CatalogEntry = $null
|
|
||||||
BaseName = $baseName
|
|
||||||
ArchiveFlavor = 'name'
|
|
||||||
RootName = $rootName
|
|
||||||
Sources = @()
|
|
||||||
Source = $Entry.Path
|
|
||||||
Error = "软件名录里没有 '$($Entry.Path)'"
|
|
||||||
}
|
}
|
||||||
}
|
else {
|
||||||
|
|
||||||
$catalogEntry = $catalog[$Entry.Path]
|
$catalogEntry = $catalog[$Entry.Path]
|
||||||
|
# 单目录条目的 RootName 沿用软件名(历史行为);多目录留空
|
||||||
|
$catalogRootName = if ($catalogEntry.Kind -eq 'Single') { $rootName } else { $null }
|
||||||
|
|
||||||
# @pathname 必须排在 Unresolved 分支之前:否则路径不存在的条目会先被
|
|
||||||
# 当作普通软件名条目返回 Flavor='name',把 @pathname 覆盖悄悄吃掉。
|
|
||||||
if ($forcePathFlavor) {
|
if ($forcePathFlavor) {
|
||||||
$flavorPath = $catalogEntry.ResolvedPath
|
# @pathname:归档名走路径算法,包内布局按源目录名
|
||||||
$flavorParent = Split-Path -Path $flavorPath -Parent
|
$archiveFlavor = 'path'
|
||||||
$flavorLeaf = Split-Path -Path $flavorPath -Leaf
|
$source = New-BackupSourceItem -Path $catalogEntry.ResolvedPath -Origin 'catalog'
|
||||||
$flavorSource = @()
|
if ($source) { $sources += $source }
|
||||||
if ($flavorParent -and $flavorLeaf) {
|
if ($catalogEntry.Kind -eq 'Unresolved') { $errorText = "名录里的路径不存在:$($catalogEntry.Path)" }
|
||||||
$flavorSource = @([pscustomobject]@{
|
|
||||||
RootName = $null
|
|
||||||
ParentDir = $flavorParent
|
|
||||||
RelativePaths = @($flavorLeaf)
|
|
||||||
SourcePath = $flavorPath
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return [pscustomobject]@{
|
else {
|
||||||
IsName = $true
|
# 名录里声明了几个目录就产出几个源 —— **当前不存在的那几个也要留着**:
|
||||||
CatalogEntry = $catalogEntry
|
# 恢复时正是要靠它们把内容还原回原位;备份端按存在性自己跳过。
|
||||||
BaseName = $baseName
|
foreach ($item in $catalogEntry.Items) {
|
||||||
ArchiveFlavor = 'path'
|
$source = New-BackupSourceItem -Path $item.Resolved -RootName $catalogRootName `
|
||||||
RootName = $null
|
-Description $item.Description -Origin 'catalog'
|
||||||
Sources = $flavorSource
|
if ($source) { $sources += $source }
|
||||||
Source = $Entry.Path
|
|
||||||
Error = $(if ($catalogEntry.Kind -eq 'Unresolved') { "名录里的路径不存在:$($catalogEntry.Path)" } else { $null })
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# 名录里的路径当前不存在。备份时这是"跳过",但**恢复时这正是要恢复的场景**,
|
|
||||||
# 所以 SourcePath 仍然给出来(恢复端会照它把内容还原回原位),
|
|
||||||
# 只把 Error 标出来让备份端跳过。
|
|
||||||
if ($catalogEntry.Kind -eq 'Unresolved') {
|
if ($catalogEntry.Kind -eq 'Unresolved') {
|
||||||
$missingPath = $catalogEntry.ResolvedPath
|
$errorText = "名录里的路径不存在:$($catalogEntry.Path)"
|
||||||
$missingParent = Split-Path -Path $missingPath -Parent
|
} elseif ($catalogEntry.Kind -eq 'Partial') {
|
||||||
$missingLeaf = Split-Path -Path $missingPath -Leaf
|
$errorText = ("名录里有 {0} 个目录当前不存在,备份会跳过它们:{1}" -f `
|
||||||
$missingSource = @()
|
$catalogEntry.Missing.Count, ($catalogEntry.Missing -join ';'))
|
||||||
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 之前),这里不再重复。
|
# ------------------------------------------------------------------
|
||||||
|
# 追加段:`:+ <路径 或 软件名>`,**两种写法都生效**
|
||||||
|
# 以前这一节只接在"软件名且能解析出目录"的那条路径后面,手写路径的 :+ 会被整段丢掉。
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
foreach ($added in @($Entry.AddedPaths)) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$added)) { continue }
|
||||||
|
$addedText = ([string]$added).Trim()
|
||||||
|
$addedPath = [Environment]::ExpandEnvironmentVariables($addedText)
|
||||||
|
|
||||||
$sources = @()
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if ($catalogEntry.Kind -in @('Multi', 'Partial')) {
|
$source = New-BackupSourceItem -Path $addedPath -Origin 'append-path'
|
||||||
# 一个软件包含多个目录:每个目录各占归档里的一个根目录层。
|
if ($source) { $sources += $source }
|
||||||
# 归档内层用**源目录自己的名字**(不用软件名),这样恢复时
|
else { Write-Log "追加项无法拆出父目录与末级名,已忽略:$addedText" -Level WARN }
|
||||||
# 每个目录都能各自找到父目录与末级名,互不干扰。
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} elseif ($catalogEntry.Kind -eq 'Single') {
|
# ------------------------------------------------------------------
|
||||||
$sources = @([pscustomobject]@{
|
# 归档内顶层同名冲突拦截
|
||||||
RootName = $rootName
|
# 7z 加进来的路径,在归档里就是**文件系统上的那个名字**(命令行没有"入库改名"的能力)。
|
||||||
ParentDir = (Split-Path -Path $catalogEntry.ResolvedPath -Parent)
|
# 所以同一个条目里出现两个同名目录(例如两个 persist)时,它们在包内会混成一棵树,
|
||||||
RelativePaths = @((Split-Path -Path $catalogEntry.ResolvedPath -Leaf))
|
# 解出来两边的内容都是错的。宁可明确报错,也不要静默搅在一起。
|
||||||
SourcePath = $catalogEntry.ResolvedPath
|
# ------------------------------------------------------------------
|
||||||
})
|
$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 {
|
} else {
|
||||||
return [pscustomobject]@{
|
$seenTop[$top] = $source.SourcePath
|
||||||
IsName = $true
|
|
||||||
CatalogEntry = $catalogEntry
|
|
||||||
BaseName = $baseName
|
|
||||||
ArchiveFlavor = 'name'
|
|
||||||
RootName = $rootName
|
|
||||||
Sources = @()
|
|
||||||
Source = $Entry.Path
|
|
||||||
Error = "名录里的路径不存在:$($catalogEntry.Path)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 清单里的 `:+ <路径>` 追加项,叠加在名录的目录之后
|
$blocking = $null
|
||||||
if ($Entry.AddedPaths -and $Entry.AddedPaths.Count -gt 0) {
|
if ($collisions.Count -gt 0) {
|
||||||
foreach ($added in $Entry.AddedPaths) {
|
$blocking = ("归档内顶层同名,无法区分:{0}。7z 不能把同一个源在包内改名,它们会在归档里混成一棵树;" +
|
||||||
$addedPath = [Environment]::ExpandEnvironmentVariables($added)
|
"请把它们拆成两个独立条目(各自一个归档)。") -f ($collisions -join ';')
|
||||||
$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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
IsName = $true
|
IsName = [bool]$isName
|
||||||
CatalogEntry = $catalogEntry
|
CatalogEntry = $catalogEntry
|
||||||
BaseName = $baseName
|
BaseName = $baseName
|
||||||
ArchiveFlavor = 'name'
|
ArchiveFlavor = $archiveFlavor
|
||||||
RootName = $rootName
|
RootName = $(if ($isName -and -not $forcePathFlavor) { $rootName } else { $null })
|
||||||
Sources = $sources
|
Sources = @($sources)
|
||||||
Source = $Entry.Path
|
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',
|
'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath',
|
||||||
'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName',
|
'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName',
|
||||||
'Get-ArchiveTopLevelNames',
|
'Get-ArchiveTopLevelNames',
|
||||||
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
|
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
|
||||||
'Get-FolderSummary',
|
'Get-FolderSummary',
|
||||||
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
|
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
|
||||||
'Get-BaknretConfig', 'Get-BaknretPassword'
|
'Get-BaknretConfig', 'Get-BaknretPassword'
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) |
|
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) |
|
||||||
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
|
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
|
||||||
| `logs/` | 每次运行的日志(已 gitignore) |
|
| `logs/` | 每次运行的日志(已 gitignore) |
|
||||||
| `tests/` | 测试:Pester 套件、零依赖套件、端到端验收、真实归档恢复演练 |
|
| `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 |
|
||||||
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
|
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
|
||||||
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
|
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
|
||||||
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
|
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
|
||||||
@@ -59,28 +59,44 @@
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@{
|
@{
|
||||||
|
# 1) 一个目录,直接写字符串
|
||||||
FooClolor = 'C:\Programs\FooClolor'
|
FooClolor = 'C:\Programs\FooClolor'
|
||||||
Kazumi = '%AppData%\com.example\Kazumi'
|
|
||||||
'scoop-config' = '%UserProfile%\.config\scoop' # 含 - 或 . 的键必须加引号
|
# 2) 一个目录 + 介绍(运行时会打印出来,推荐)
|
||||||
'.ssh' = '%UserProfile%\.ssh'
|
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` 解析成减法表达式并报
|
要点:
|
||||||
|
|
||||||
|
- **含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报
|
||||||
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。
|
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。
|
||||||
|
- 数组元素也接受**纯字符串**(`scoop = @('D:\a', 'D:\b')`),以及旧的
|
||||||
两个便利特性:
|
`@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法 —— 三种都能用。
|
||||||
|
- **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道
|
||||||
1. **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。
|
"这块内容原本该回到哪个位置",这正是恢复要用的。
|
||||||
|
- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。
|
||||||
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。
|
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。
|
||||||
2. **同名目录在多处**时显式列出,所有位置都会打进同一个归档:
|
- **一个软件里不能有两个同名目录**(例如两个 `persist`):归档内的顶层名就是目录名,
|
||||||
|
那样会在包里混成一棵树。脚本会明确报错(退出码 1)让你拆成两个条目。
|
||||||
```powershell
|
|
||||||
ImHex = @{
|
|
||||||
Path = 'D:\Hex\ImHex'
|
|
||||||
Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
|
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
|
||||||
|
|
||||||
@@ -94,15 +110,15 @@
|
|||||||
## BackupList.txt 语法
|
## BackupList.txt 语法
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
<软件名 或 手写目录> [ :+ <再追加一个目录/软件名> ... ] [ :- <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||||
```
|
```
|
||||||
|
|
||||||
三种写法可以混用:
|
### 两种写法(混用没问题)
|
||||||
|
|
||||||
| 写法 | 说明 |
|
| 写法 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `FooClolor` | 软件名。去名录查目录,**归档名 = 软件名** |
|
| `FooClolor` | **软件名**:去 `SoftwareCatalog.psd1` 查目录,**归档名 = 软件名**。一个软件可以挂多个目录 |
|
||||||
| `%UserProfile%\Documents\PowerShell` | 字面路径(含 `\` `/` 或 `%` 就按路径处理),归档名沿用 `<末级名>_from_<上级路径>` |
|
| `%UserProfile%\Documents\PowerShell` | **手写目录**:含 `\` `/` 或 `%` 就按路径处理,归档名沿用 `<末级名>_from_<上级路径>` |
|
||||||
| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 |
|
| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 |
|
||||||
|
|
||||||
| 标记 | 作用 |
|
| 标记 | 作用 |
|
||||||
@@ -111,10 +127,60 @@
|
|||||||
| `pathname` | 用路径命名算法而不是软件名 |
|
| `pathname` | 用路径命名算法而不是软件名 |
|
||||||
| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 |
|
| `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!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。
|
- **模式里不要写引号。** `-x!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。
|
||||||
- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。
|
- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。
|
||||||
@@ -152,11 +218,17 @@
|
|||||||
.\tools\Rename-Archives.ps1 -Apply # 确认后执行
|
.\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`。
|
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
|
||||||
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
|
- `-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-Tests.ps1` | 只要 PowerShell + 7z | 49 项:同样的单元面,适合没装 Pester 的机器 |
|
||||||
| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 23 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 |
|
| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 23 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 |
|
||||||
| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 把 `Backups/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) |
|
| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 把 `Backups/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) |
|
||||||
@@ -293,6 +365,11 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
|
|||||||
| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) |
|
| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) |
|
||||||
| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报 |
|
| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报 |
|
||||||
| `Resolve-BackupEntry` 里的 `$rootName` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 |
|
| `Resolve-BackupEntry` 里的 `$rootName` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 |
|
||||||
|
| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录展开 / 字面路径) |
|
||||||
|
| 软件名录的多目录写法 | 只有 `@{ Dirs = @(...) }`,没有"这个目录是干什么的" | 支持**对象数组**(`Path` + `Description`),运行时逐条介绍 |
|
||||||
|
| 多目录条目的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个源只解出**它自己那棵子树** |
|
||||||
|
| 同一条目里两个同名目录 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示拆成两个条目 |
|
||||||
|
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由 |
|
||||||
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
|
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
|
||||||
|
|
||||||
## 设计取舍(有意为之,不是遗漏)
|
## 设计取舍(有意为之,不是遗漏)
|
||||||
@@ -310,6 +387,6 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
|
|||||||
- 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
|
- 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
|
||||||
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
|
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
|
||||||
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
|
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
|
||||||
- `Variants`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。
|
- `Variants`(同名目录分散在多处)当前打包第一个位置;恢复时每个源只解出**它自己那棵子树**,不会把兄弟目录复制到别的父目录下。
|
||||||
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
|
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
|
||||||
- **磁盘空间守卫是逐条目判断的**,不预留"本次运行后续条目"的空间。`MinFreeSpaceGB` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。
|
- **磁盘空间守卫是逐条目判断的**,不预留"本次运行后续条目"的空间。`MinFreeSpaceGB` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。
|
||||||
+39
-8
@@ -158,7 +158,7 @@ function Get-7zExecutable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-Extraction {
|
function Invoke-Extraction {
|
||||||
param([object]$ArchiveFile, [string]$DestinationPath)
|
param([object]$ArchiveFile, [string]$DestinationPath, [string]$RelativePath)
|
||||||
|
|
||||||
$extension = $ArchiveFile.Extension.ToLower()
|
$extension = $ArchiveFile.Extension.ToLower()
|
||||||
$destParent = Split-Path -Path $DestinationPath -Parent
|
$destParent = Split-Path -Path $DestinationPath -Parent
|
||||||
@@ -167,14 +167,17 @@ function Invoke-Extraction {
|
|||||||
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
|
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
# 归档布局与历史保持一致:根目录就是源目录名(软件名条目也一样,
|
# 归档布局与历史保持一致:顶层就是**源目录名**(软件名只用于归档文件名)。
|
||||||
# 软件名只用于归档文件名),因此直接整包解压到目标的父目录即可。
|
# 一个条目可能打包了好几个目录(软件名录里的数组写法 / `:+` 追加),
|
||||||
|
# 所以**不能整包往每个目标里倒** —— 那会把兄弟目录也复制到不相干的父目录下。
|
||||||
|
# 这里只解出该目标自己那棵子树($RelativePath),其余不动。
|
||||||
$sevenZip = Get-7zExecutable
|
$sevenZip = Get-7zExecutable
|
||||||
if ($sevenZip) {
|
if ($sevenZip) {
|
||||||
Write-Log '使用 7z 解压' -Level DEBUG
|
Write-Log '使用 7z 解压' -Level DEBUG
|
||||||
$argument = @('x', '-bsp2', '-y', "-o$destParent")
|
$argument = @('x', '-bsp2', '-y', "-o$destParent")
|
||||||
if ($password) { $argument += "-p$password" }
|
if ($password) { $argument += "-p$password" }
|
||||||
$argument += $ArchiveFile.FullName
|
$argument += $ArchiveFile.FullName
|
||||||
|
if ($RelativePath) { $argument += $RelativePath }
|
||||||
|
|
||||||
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
|
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
|
||||||
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
|
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
|
||||||
@@ -187,18 +190,24 @@ function Invoke-Extraction {
|
|||||||
if (-not $rarExe) { throw '未找到 RAR 工具' }
|
if (-not $rarExe) { throw '未找到 RAR 工具' }
|
||||||
Write-Log '使用 RAR 解压' -Level DEBUG
|
Write-Log '使用 RAR 解压' -Level DEBUG
|
||||||
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\")
|
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\")
|
||||||
|
if ($RelativePath) { $argument += $RelativePath }
|
||||||
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
|
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
|
||||||
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
|
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
|
||||||
}
|
}
|
||||||
'.zip' {
|
'.zip' {
|
||||||
Write-Log '使用内置 ZIP 解压' -Level DEBUG
|
Write-Log '使用内置 ZIP 解压' -Level DEBUG
|
||||||
|
if ($RelativePath) {
|
||||||
|
Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN
|
||||||
|
}
|
||||||
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force
|
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force
|
||||||
}
|
}
|
||||||
'.tar' {
|
'.tar' {
|
||||||
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||||
if (-not $tarExe) { throw '未找到 TAR 工具' }
|
if (-not $tarExe) { throw '未找到 TAR 工具' }
|
||||||
Write-Log '使用 TAR 解压' -Level DEBUG
|
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)" }
|
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
|
||||||
}
|
}
|
||||||
default { throw "不支持的文件格式:$extension" }
|
default { throw "不支持的文件格式:$extension" }
|
||||||
@@ -305,14 +314,26 @@ foreach ($line in $lines) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
# 恢复目的地。清单条目可能带多个源(同名目录分散在多处),每个源各恢复各的。
|
# 恢复目的地。一个条目可能带多个源(软件名录里的数组写法、`:+` 追加、
|
||||||
|
# 或同名目录分散在多处),每个源只还原**它自己那棵子树**。
|
||||||
$targets = @()
|
$targets = @()
|
||||||
if ($resolved.Sources.Count -gt 0) {
|
if ($resolved.Sources.Count -gt 0) {
|
||||||
foreach ($source in $resolved.Sources) {
|
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 {
|
} 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
|
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
|
||||||
@@ -388,6 +409,16 @@ foreach ($line in $lines) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$plannedTargets = @($targets | Where-Object { $_.DestPath })
|
$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) {
|
foreach ($target in $plannedTargets) {
|
||||||
if (Test-Path -LiteralPath $target.DestPath) { continue }
|
if (Test-Path -LiteralPath $target.DestPath) { continue }
|
||||||
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
|
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
|
||||||
@@ -415,7 +446,7 @@ foreach ($line in $lines) {
|
|||||||
$restoreFailed = $false
|
$restoreFailed = $false
|
||||||
try {
|
try {
|
||||||
foreach ($target in $plannedTargets) {
|
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
|
$restoreFailed = $true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-27
@@ -31,7 +31,7 @@
|
|||||||
1. 目录不存在时会按前缀补全:写 'D:\Programs\legendary',实际目录是
|
1. 目录不存在时会按前缀补全:写 'D:\Programs\legendary',实际目录是
|
||||||
'D:\Programs\legendary_2.0.4',会自动匹配(只认 `<名>_*` 与 `<名>-*`,
|
'D:\Programs\legendary_2.0.4',会自动匹配(只认 `<名>_*` 与 `<名>-*`,
|
||||||
不会把 Legendary 误配成 LegendarySomething)。
|
不会把 Legendary 误配成 LegendarySomething)。
|
||||||
2. **一个软件包含多个目录**时,用 Dirs 数组列出,全部打进同一个归档:
|
2. **一个软件包含多个目录**时,写成**对象数组**(每个目录带自己的说明),全部打进同一个归档:
|
||||||
|
|
||||||
scoop = @{ Dirs = @(
|
scoop = @{ Dirs = @(
|
||||||
'%UserProfile%\scoop\persist'
|
'%UserProfile%\scoop\persist'
|
||||||
@@ -39,8 +39,13 @@
|
|||||||
'%UserProfile%\.config\scoop'
|
'%UserProfile%\.config\scoop'
|
||||||
) }
|
) }
|
||||||
|
|
||||||
归档里每个目录仍是自己的名字与层级,恢复时各自还原回原位。
|
归档里每个目录仍是自己的名字与层级,恢复时会**只解出该目录自己那棵子树**,
|
||||||
同名目录出现在多个位置的旧写法 Variants 等价于 Dirs,继续可用。
|
各自还原回原位,不会把兄弟目录也复制过去。
|
||||||
|
纯字符串数组、以及旧的 `@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法继续可用。
|
||||||
|
|
||||||
|
注意:归档内的顶层名就是目录自己的名字,所以**同一个软件里不能有两个同名目录**
|
||||||
|
(典型例子是两个都叫 persist 的目录)。那种情况脚本会明确报错并让你拆成两个条目,
|
||||||
|
而不是把两棵树悄悄混在一起。
|
||||||
|
|
||||||
分文件维护:用 Includes 引入其它名录文件(路径相对本文件):
|
分文件维护:用 Includes 引入其它名录文件(路径相对本文件):
|
||||||
|
|
||||||
@@ -50,41 +55,103 @@
|
|||||||
}
|
}
|
||||||
#>
|
#>
|
||||||
@{
|
@{
|
||||||
|
# 每个条目有两种写法:
|
||||||
|
# 1. 只写一个目录字符串: legendary = '%UserProfile%\.config\legendary'
|
||||||
|
# 2. 带目录介绍(推荐):
|
||||||
|
# legendary = @{
|
||||||
|
# Path = '%UserProfile%\.config\legendary'
|
||||||
|
# Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录'
|
||||||
|
# }
|
||||||
|
# 一个软件包含**多个目录**时,写成对象数组(见下面的 scoop)。
|
||||||
|
# 运行时会把"这个条目打包哪些目录、每个目录是干什么的、排除了什么、为什么"
|
||||||
|
# 逐条打印出来,说明就来自这里。
|
||||||
|
|
||||||
# ---- 用户配置 / 开发环境 ----
|
# ---- 用户配置 / 开发环境 ----
|
||||||
# 含 `-` 或 `.` 的键必须加引号,否则会被当成减法表达式(见文件开头说明)
|
# 含 `-` 或 `.` 的键必须加引号,否则会被当成减法表达式(见文件开头说明)
|
||||||
legendary = '%UserProfile%\.config\legendary'
|
legendary = @{
|
||||||
opencode = '%UserProfile%\.config\opencode'
|
Path = '%UserProfile%\.config\legendary'
|
||||||
'scoop-config' = '%UserProfile%\.config\scoop'
|
Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录'
|
||||||
'scoop-persist' = '%UserProfile%\scoop\persist'
|
}
|
||||||
'.ssh' = '%UserProfile%\.ssh'
|
opencode = @{
|
||||||
CodeSpace = 'D:\UserData\Documents\CodeSpace'
|
Path = '%UserProfile%\.config\opencode'
|
||||||
PowerShell = '%UserProfile%\Documents\PowerShell'
|
Description = 'opencode 的配置'
|
||||||
WindowsPowerShell = '%UserProfile%\Documents\WindowsPowerShell'
|
}
|
||||||
|
|
||||||
|
# 一个软件 = 一个归档;多个目录写成**对象数组**,每个目录各自带说明。
|
||||||
|
# 注意:归档内的顶层名字就是**目录自己的名字**,所以同一个软件里不能有两个同名目录
|
||||||
|
# (例如两个 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'
|
AutoDarkMode = @{ Path = '%AppData%\AutoDarkMode'; Description = 'AutoDarkMode 的主题/时间设置' }
|
||||||
Kazumi = '%AppData%\com.example\Kazumi'
|
Kazumi = @{ Path = '%AppData%\com.example\Kazumi'; Description = 'Kazumi 的观看记录与设置' }
|
||||||
piliplus = '%AppData%\com.example\piliplus'
|
piliplus = @{ Path = '%AppData%\com.example\piliplus'; Description = 'piliplus 的设置与账号数据' }
|
||||||
fnm = '%AppData%\fnm'
|
fnm = @{ Path = '%AppData%\fnm'; Description = 'fnm(Node 版本管理器)的版本记录' }
|
||||||
'twinkle-tray' = '%AppData%\twinkle-tray'
|
'twinkle-tray' = @{ Path = '%AppData%\twinkle-tray'; Description = 'Twinkle Tray 的显示器亮度设置' }
|
||||||
|
|
||||||
# ---- 浏览器与终端 ----
|
# ---- 浏览器与终端 ----
|
||||||
Edge = '%LocalAppData%\Microsoft\Edge\User Data'
|
# Edge 的缓存/扩展本体等可再生内容由 BackupList.txt 的 :- 排除规则挡掉
|
||||||
WindowsTerminal = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
|
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 ----
|
# ---- C:\Programs ----
|
||||||
BaiduNetdisk = 'C:\Programs\BaiduNetdisk'
|
BaiduNetdisk = @{ Path = 'C:\Programs\BaiduNetdisk'; Description = '百度网盘客户端' }
|
||||||
|
# FooClolor 的具体用途不明确,先不加介绍(没有 Description 也不会影响打包)
|
||||||
FooClolor = 'C:\Programs\FooClolor'
|
FooClolor = 'C:\Programs\FooClolor'
|
||||||
March7thAssistant = 'C:\Programs\March7thAssistant'
|
March7thAssistant = @{
|
||||||
MiFlash = 'C:\Programs\MiFlash'
|
Path = 'C:\Programs\March7thAssistant'
|
||||||
MiFlash_Unlock = 'C:\Programs\MiFlash_Unlock'
|
Description = '三月七助手(WebBrowser 用户目录里的缓存由 BackupList.txt 排除)'
|
||||||
QuarkCloudDrive = 'C:\Programs\QuarkCloudDrive'
|
}
|
||||||
translucenttb = 'C:\Programs\ScoopApps\apps\translucenttb\current\settings.json'
|
MiFlash = @{ Path = 'C:\Programs\MiFlash'; Description = '小米刷机工具 MiFlash' }
|
||||||
'ScoopApps-persist' = 'C:\Programs\ScoopApps\persist'
|
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 下载器的配置与任务' }
|
||||||
}
|
}
|
||||||
@@ -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 里还留着它的历史记录'
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-29
@@ -211,29 +211,40 @@ foreach ($name in $Entries) {
|
|||||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' }
|
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ($sources.Count -gt 1) {
|
|
||||||
# 多目录条目恢复时会整包解压到每个位置,逐个对拍意义不大,默认不演练
|
if (@($sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }).Count -eq 0) {
|
||||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多目录条目($($sources.Count) 个源),不在演练范围内" }
|
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
$liveSource = $sources[0].SourcePath
|
$entryRoot = Join-Path (Join-Path $WorkRoot 'restore') $name
|
||||||
if (-not (Test-Path -LiteralPath $liveSource)) {
|
New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null
|
||||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "活源不存在,无法对拍:$liveSource" }
|
|
||||||
continue
|
# 每个源各自映射到一个临时目标:临时名录保持**同样的个数与顺序**,
|
||||||
|
# 于是 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
|
# 临时名录:把这些目录全指到临时目标,Restore 就解到这里,碰不到真实目录
|
||||||
$restoreParent = Join-Path (Join-Path $WorkRoot 'restore') $name
|
|
||||||
$scratchTarget = Join-Path $restoreParent $leaf
|
|
||||||
New-Item -ItemType Directory -Path $restoreParent -Force | Out-Null
|
|
||||||
|
|
||||||
# 临时名录:把这个软件名指到临时目标,Restore 就会解到这里,碰不到真实目录
|
|
||||||
$scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1")
|
$scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1")
|
||||||
$scratchList = Join-Path $WorkRoot ("list-$name.txt")
|
$scratchList = Join-Path $WorkRoot ("list-$name.txt")
|
||||||
$scratchConfig = Join-Path $WorkRoot ("config-$name.psd1")
|
$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($scratchList, "$name`n", [System.Text.UTF8Encoding]::new($false))
|
||||||
[System.IO.File]::WriteAllText($scratchConfig, @"
|
[System.IO.File]::WriteAllText($scratchConfig, @"
|
||||||
@{
|
@{
|
||||||
@@ -245,7 +256,7 @@ foreach ($name in $Entries) {
|
|||||||
}
|
}
|
||||||
"@, [System.Text.UTF8Encoding]::new($false))
|
"@, [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,子进程既不会打断演练,
|
# 用**子进程**跑 Restore.ps1:它结尾会 exit,子进程既不会打断演练,
|
||||||
# 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。
|
# 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。
|
||||||
@@ -270,37 +281,54 @@ foreach ($name in $Entries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$checked++
|
$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'
|
$status = 'PASS'
|
||||||
|
|
||||||
if ($report.Extra.Count -gt 0) {
|
if ($extra.Count -gt 0) {
|
||||||
$status = 'FAIL'
|
$status = 'FAIL'
|
||||||
$detail += ";归档里有源里没有的 $($report.Extra.Count) 个文件"
|
$detail += ";归档里有源里没有的 $($extra.Count) 个文件"
|
||||||
$failures += "$name :恢复出源里没有的文件(首例 $($report.Extra[0]))"
|
$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) {
|
if ($AllowChanged) {
|
||||||
$detail += ";与活源不一致 $($report.Changed.Count) 个(-AllowChanged,已容忍)"
|
$detail += ";与活源不一致 $($changed.Count) 个(-AllowChanged,已容忍)"
|
||||||
} else {
|
} else {
|
||||||
$status = 'FAIL'
|
$status = 'FAIL'
|
||||||
$detail += ";与活源不一致 $($report.Changed.Count) 个(首例 $($report.Changed[0]))"
|
$detail += ";与活源不一致 $($changed.Count) 个(首例 $($changed[0]))"
|
||||||
$failures += "$name :与活源不一致(首例 $($report.Changed[0]))"
|
$failures += "$name :与活源不一致(首例 $($changed[0]))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (($report.Matched + $report.Stale.Count) -eq 0) {
|
if (($matched + $stale.Count) -eq 0) {
|
||||||
$status = 'FAIL'
|
$status = 'FAIL'
|
||||||
$detail += ";没有任何文件能对上(多半是空归档)"
|
$detail += ";没有任何文件能对上(多半是空归档)"
|
||||||
$failures += "$name :恢复出 0 个可对拍的文件"
|
$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 }
|
$rows += [pscustomobject]@{ Entry = $name; Status = $status; Detail = $detail }
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ param(
|
|||||||
|
|
||||||
[string[]]$Tag,
|
[string[]]$Tag,
|
||||||
|
|
||||||
[string]$TestPath = (Join-Path $PSScriptRoot 'BakNRet.Tests.ps1')
|
# 默认跑 tests/ 下所有 *.Tests.ps1(Pester 按这个命名约定发现)
|
||||||
|
[string]$TestPath = $PSScriptRoot
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) {
|
|||||||
|
|
||||||
$oldNameSource = $item.Path
|
$oldNameSource = $item.Path
|
||||||
if ($resolved.IsName -and $resolved.CatalogEntry) { $oldNameSource = $resolved.CatalogEntry.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
|
$oldName = Get-BackupBaseName -RawPath $oldNameSource
|
||||||
|
|
||||||
if (-not $oldName -or -not $newName) { continue }
|
if (-not $oldName -or -not $newName) { continue }
|
||||||
|
|||||||
Reference in new issue
Block a user