dsh-recall-plugin 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,778 +1,537 @@
1
- /**
2
- * dsh-recall-plugin PowerShell 脚本模板(纯函数,无 ctx 依赖,win32 专用)
3
- *
4
- * 职责:集中拼装所有发给 shell PowerShell 脚本文本。只做字符串构造,
5
- * 不执行、无状态;执行侧(runShell)见 store.js,调用侧见
6
- * snapshots.js / maintenance.js。集中在这里是为了:
7
- * 1) PS 5.1 / pwsh 7 双版本兼容的坑只在一处处理;
8
- * 2) 脚本片段(gitlink 清理、超大文件排除、用户排除同步)在多个
9
- * 流程里逐字复用,散落各处必然改漏。
10
- * POSIX(Linux/macOS)对应模板见 scripts.posix.js,两者导出同名接口,
11
- * 由 store.js 按 process.platform 选择。
12
- */
13
-
14
- // 单引号字面量转义:PS 单引号串里只有 '' 表示一个单引号,且不展开变量,
15
- // 是把 JS 值安全嵌进命令串的唯一可靠方式(杜绝 $、反引号注入)。
16
- export function psq(value) {
17
- return "'" + String(value).replace(/'/g, "''") + "'"
18
- }
19
-
20
- // 统一 UTF-8 输出前导:中文等非 ASCII 机器的默认代码页(如 GBK)下,
21
- // PowerShell 重定向 stdout 按 [Console]::OutputEncoding 编码,而 DSH 按
22
- // UTF-8 解码——不强制时含中文的用户名/路径会变乱码。PS 5.1 / 7 均支持。
23
- export const UTF8_PRELUDE = '$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}'
24
-
25
- // 超大文件跳过阈值:git 对象库对大文件极不友好,回退语义也不该被一个
26
- // 200MB 的构建产物拖垮。默认值与 config.js 的 maxFileBytes 一致;实际
27
- // 生效值以 store.maxFileBytes(用户 config 可调)经 oversizeBlock 注入为准。
28
- export const MAX_FILE_BYTES = 104857600
29
-
30
- // 失败清扫的 stale 锁阈值(分钟,M3)与心跳有效窗口(秒,M3):语义、取值
31
- // 理由与「不走 Config」的定性见 scripts.posix.js 同名常量注释,两侧必须
32
- // 同值(scripts-contract 钉)。
33
- export const STALE_LOCK_MIN = 5
34
- export const HEARTBEAT_TTL_S = 900
35
-
36
- // 影子仓库 info/attributes 固化内容(issue #12 字节保真,两套模板同值,
37
- // scripts-contract 钉)。为什么必须:回退恢复走 git archive、捕获走 add,
38
- // 两者都会应用「快照树里项目自己的 .gitattributes」——text=auto(极常见)
39
- // + 缺省 core.eol=native(Windows CRLF)会让 archive 把 LF 转 CRLF,
40
- // 仓库级 core.autocrlf=false 挡不住(属性驱动的转换看 core.eol,不看
41
- // autocrlf,实测)。info/attributes 是优先级最高的属性源,对全部路径
42
- // 无条件关闭内容转换,capture/restore 两侧逐字节保真;也顺带治掉
43
- // ensureGit git config 写入静默失败(I14:pwsh 对 native 非零不抛)
44
- // 后裸露 system autocrlf 的帮凶路径。逐项语义:
45
- // -text 关闭全部 EOL 转换(含 autocrlf/text/eol 全部语义)
46
- // -filter 关闭 clean/smudge 外部过滤命令(LFS 类,防内容被改写/存指针)
47
- // -ident 关闭 $Id$ 展开
48
- // -export-ignore 防归档静默丢文件(回退恢复缺文件零报错,实测)
49
- // -export-subst 防归档内容 $Format:$ 替换
50
- // -working-tree-encoding 防编码转码
51
- // 快照是备份/还原不是 VCS:字节保真优先于用户在 .gitattributes 里声明的
52
- // 任何转换策略——那是给 git 仓库的,不是给影子快照库的。
53
- export const FIDELITY_ATTRS = '* -text -filter -ident -export-ignore -export-subst -working-tree-encoding'
54
-
55
- // 去除 PS 5.1 Set-Content -Encoding utf8 写出的 BOM:JSON 解析前必须剥掉,
56
- // 否则 JSON.parse 把 BOM 当正文首字符直接抛错。
57
- export function stripBom(text) {
58
- return text.replace(/^\uFEFF/, '')
59
- }
60
-
61
- // 嵌套 git 仓库(工作区里的子项目自带 .git)会被 add -A 记成 gitlink(160000);
62
- // gitlink 残留在 index add -A 会 fatal "in unpopulated submodule",
63
- // gitlink 对文件回退毫无意义——所以 add 前后各清一次,子仓库内容不进快照。
64
- // 依赖外层脚本已定义的 $git/$g;被 snapshot/diff/rollback 三处复用。
65
- function dropGitlinksBlock() {
66
- return [
67
- "& $git --git-dir=$g ls-files --stage | Where-Object { $_ -like '160000*' } | ForEach-Object {",
68
- " $p = ($_ -split \"`t\")[1]",
69
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $p',
70
- '}'
71
- ].join('\n')
72
- }
73
-
74
- // 剔除超大文件:.NET 手动栈遍历(PF-3)替代 Get-ChildItem -Recurse——后者
75
- // 每文件走一遍 PowerShell 管道对象,几万文件时数秒,且 snapshot/diff/rollback
76
- // 三条脚本各调一次(一次完整撤回 4 次全工作区枚举)。手动栈是 .NET 4.x
77
- // (PS 5.1 可用范围)的唯一逐目录容错形态:EnumerateFiles(..., AllDirectories)
78
- // ACL 异常目录会中断整个枚举(没有 .NET 6 的 SkipUnavailable),必须
79
- // 逐目录 try/catch 才能与 Get-ChildItem -ErrorAction SilentlyContinue
80
- // 逐项容错语义对齐——本扫描只用于排除超大文件,漏看个别文件是 fail-open,
81
- // 可接受。DirectoryInfo.EnumerateFiles 返回全部文件(含隐藏/系统),等价
82
- // Get-ChildItem -Force;阈值按调用注入(store.maxFileBytes,config 可调)。
83
- // PF-9 合批:命中路径先收进 List 再多路径合参(每批 100,与 purgeTags
84
- // 分块同款纪律),update-index 子进程数 N → N/100。
85
- // 依赖外层已定义的 $git/$g/$root。
86
- function oversizeBlock(maxBytes) {
87
- return [
88
- '$oversizeStack = [System.Collections.Generic.Stack[string]]::new()',
89
- '$oversizeStack.Push($root)',
90
- '$oversizeRel = [System.Collections.Generic.List[string]]::new()',
91
- 'while ($oversizeStack.Count -gt 0) {',
92
- ' $dir = $oversizeStack.Pop()',
93
- ' try {',
94
- ' $di = [System.IO.DirectoryInfo]::new($dir)',
95
- ' foreach ($f in $di.EnumerateFiles()) {',
96
- ' if ($f.Length -gt ' + String(maxBytes || MAX_FILE_BYTES) + ') {',
97
- " $oversizeRel.Add($f.FullName.Substring($root.Length + 1).Replace('\\','/'))",
98
- ' }',
99
- ' }',
100
- ' foreach ($d in $di.EnumerateDirectories()) { $oversizeStack.Push($d.FullName) }',
101
- ' } catch {}',
102
- '}',
103
- 'for ($i = 0; $i -lt $oversizeRel.Count; $i += 100) {',
104
- ' $batch = $oversizeRel.GetRange($i, [Math]::Min(100, $oversizeRel.Count - $i))',
105
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch',
106
- '}'
107
- ].join('\n')
108
- }
109
-
110
- // 用户自定义排除同步:把基础排除表与用户 exclude.txt 合并重写进 info/exclude,
111
- // 再用 ls-files -i -c 找出「已被跟踪但命中排除」的条目从 index 清掉。
112
- // - 只用 --exclude-from 指 info/exclude,不用 --exclude-standard:后者会
113
- // 连带项目自己的 .gitignore 语义(后加 ignore 的已跟踪文件会被悄悄移出
114
- // 快照),行为超出用户配置的本意。
115
- // - 放在 add -A 之前:排除表先生效,新增的排除路径根本不会被暂存,
116
- // 已跟踪的旧条目由 ls-files -i -c 补刀,两条路径一次覆盖。
117
- // - 首行留空元素吸收 PS 5.1 utf8 BOM(BOM 粘在首行会废掉第一条模式),
118
- // 与下方 ensureGitScript 的老技巧一致。
119
- // - base 基础排除表按调用注入(config.baseExcludes 可调),不硬编码。
120
- // - 依赖外层已定义的 $git/$g;被 ensureGit/snapshot/diff/rollback 复用,
121
- // 因此 exclude.txt 的改动在下一次快照/diff/回退时即时生效,无需重启。
122
- // - PF-9 条件化:写 info/exclude 前逐行比对旧文件(按行比较天然免疫
123
- // BOM/行尾差异——Get-Content -Encoding UTF8 BOM、剥行尾),内容相同
124
- // 则跳过重写**并跳过**清理循环(每条消息常态省 1 次 git 子进程 + 1 次
125
- // 盘写)。语义安全:exclude 未变时上次快照已把命中条目移出 index,
126
- // add -A 因排除先生效不会加回;首次应用新 exclude 或改动后仍走完整
127
- // 链路,「改排除即时生效」承诺(AGENTS.md 钉)不变。
128
- // - PF-9 合批:清理循环逐条 update-index 每次 fork 一个 git 子进程,改为
129
- // 多路径合参(每批 100,与 purgeTags 分块同款纪律)N 次 → N/100。
130
- function excludeSyncBlock(excludeFile, base) {
131
- // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
132
- // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
133
- const baseList = (Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']).map(psq).join(',')
134
- return [
135
- "$exFile = " + psq(excludeFile),
136
- '$userPats = @()',
137
- "if (Test-Path -LiteralPath $exFile) { $userPats = @(Get-Content -LiteralPath $exFile -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $t = $_.Trim(); $t -and -not $t.StartsWith('#') }) }",
138
- "$lines = @('') + @(" + baseList + ") + $userPats",
139
- "$exc = Join-Path $g 'info\\exclude'",
140
- '$excOld = @(Get-Content -LiteralPath $exc -Encoding UTF8 -ErrorAction SilentlyContinue)',
141
- '$same = ($excOld.Count -eq $lines.Count)',
142
- 'if ($same) {',
143
- ' for ($i = 0; $i -lt $lines.Count; $i++) {',
144
- ' if ($excOld[$i] -ne $lines[$i]) { $same = $false; break }',
145
- ' }',
146
- '}',
147
- 'if (-not $same) {',
148
- ' Set-Content -LiteralPath $exc -Value $lines -Encoding utf8',
149
- ' $hit = @(& $git -c core.quotePath=false --literal-pathspecs --git-dir=$g ls-files -i -c --exclude-from=$exc | Where-Object { $_ })',
150
- ' for ($i = 0; $i -lt $hit.Count; $i += 100) {',
151
- ' $batch = @($hit[$i..([Math]::Min($i + 99, $hit.Count - 1))])',
152
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch',
153
- ' }',
154
- '}',
155
- ].join('\n')
156
- }
157
-
158
- // 心跳写入(M3,语义见 scripts.posix.js 同名注释):Set-Content ascii
159
- // 编码——内容纯数字,关键是绝不能带 BOM(POSIX 侧按空白分词解析首字段,
160
- // 5.1 的 utf8 默认编码会写 BOM 破坏首字段);-ErrorAction SilentlyContinue
161
- // 让心跳写失败不连累外层 EAP=Stop 的快照主流程(与 POSIX || true 对齐)。
162
- // 依赖外层已定义的 $g;被 ensureGitScript / snapshotScript 复用。
163
- function heartbeatBlock() {
164
- return [
165
- "$hbf = Join-Path (Split-Path -Parent (Split-Path -Parent $g)) 'heartbeat'",
166
- "Set-Content -LiteralPath $hbf -Value ('" + String(process.pid) + " ' + [DateTimeOffset]::Now.ToUnixTimeSeconds()) -Encoding ascii -ErrorAction SilentlyContinue"
167
- ].join('\n')
168
- }
169
-
170
- // 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,脚本里用绝对路径调用。
171
- // 逐项判空再 Join-Path:个别 env 在特殊环境(32 位系统无 ProgramFiles(x86))
172
- // 取到 null,EAP=Stop 下 Join-Path 抛错会让整个探测失败、误报 gitMissing。
173
- export function resolveGitScript() {
174
- return [
175
- '$candidates = @()',
176
- '$g = (Get-Command git -ErrorAction SilentlyContinue).Source',
177
- 'if ($g) { $candidates += $g }',
178
- "if (${env:ProgramFiles}) { $candidates += (Join-Path ${env:ProgramFiles} 'Git\\cmd\\git.exe') }",
179
- "if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'Git\\cmd\\git.exe') }",
180
- "if (${env:LocalAppData}) { $candidates += (Join-Path ${env:LocalAppData} 'Programs\\Git\\cmd\\git.exe') }",
181
- "$g = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1",
182
- 'if ($g) { Write-Output $g }'
183
- ].join('\n')
184
- }
185
-
186
- // 计算项目对应的 home 存储目录(DSH_HOME 优先,否则 ~/.dsh)。
187
- // 哈希用 Create()+ComputeHash+BitConverter 而不是 HashData+ToHexString:
188
- // 后两者是 .NET 5+(仅 PS 7)API,别人机器的 shell 若是 Windows PowerShell
189
- // 5.1 会抛错,导致 home 存储永远降级到项目内;前者两个版本都可用。
190
- export function homeDirScript(root, envHome) {
191
- return [
192
- '$r = ' + psq(root),
193
- "$h = if ($env:DSH_HOME) { $env:DSH_HOME } elseif (" + psq(envHome) + ") { " + psq(envHome) + " } else { Join-Path $env:USERPROFILE \".dsh\" }",
194
- '$sha = [Security.Cryptography.SHA256]::Create()',
195
- "$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
196
- "Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
197
- ].join('\n')
198
- }
199
-
200
- export function mkdirScript(dir) {
201
- return 'New-Item -ItemType Directory -Force -Path ' + psq(dir) + ' | Out-Null'
202
- }
203
-
204
- // 旧版迁移:把降级时代落在项目内的影子仓库整体搬回 home 并删源目录
205
- export function migrateScript(src, dst) {
206
- return [
207
- "$ErrorActionPreference = 'Stop'",
208
- '$src = ' + psq(src),
209
- '$dst = ' + psq(dst),
210
- "if (Test-Path -LiteralPath (Join-Path $src 'git')) { Move-Item -LiteralPath (Join-Path $src 'git') -Destination (Join-Path $dst 'git') -Force }",
211
- "if (Test-Path -LiteralPath (Join-Path $src 'index.json')) { Move-Item -LiteralPath (Join-Path $src 'index.json') -Destination (Join-Path $dst 'index.json') -Force }",
212
- 'Remove-Item -Recurse -Force -LiteralPath $src -ErrorAction SilentlyContinue',
213
- "Write-Output 'MIGRATE_OK'"
214
- ].join('\n')
215
- }
216
-
217
- // 建立影子仓库:普通 init(index 留在仓库内跨快照复用,git add 的 stat 缓存
218
- // 让未变文件近乎零成本),core.longpaths 放开 Windows 深路径。
219
- // autocrlf=false:按原始字节入快照(回退时逐字节还原),也避免用户全局
220
- // autocrlf=true 时的 LF/CRLF stderr 警告;addEmbeddedRepo=false:嵌套仓库
221
- // hint/warning stderr,在 DSH shell(EAP=Stop)下会让整条脚本非零退出,
222
- // 必须在仓库级配置里静默掉。
223
- // info/attributes 固化(issue #12,内容见 FIDELITY_ATTRS):info/ git init
224
- // 自带(info/exclude 模板,excludeSyncBlock 同样依赖),无需建目录;每次
225
- // ensureGit 重写幂等,存量仓库升级后首次 init 自然补上。
226
- // 结尾回读 gc.stamp(maintenance.js 上次 gc 时间戳):让重启后的 gc 节流
227
- // 不归零——没有它,天天重启 DSH 的用户每次开机第一条消息都会触发一次
228
- // 全量 gc,纯浪费。
229
- export function ensureGitScript(store, gitExe, base) {
230
- return [
231
- "$ErrorActionPreference = 'Stop'",
232
- '$git = ' + psq(gitExe),
233
- '$repo = ' + psq(store.repo),
234
- '$g = ' + psq(store.git),
235
- heartbeatBlock(),
236
- 'if (-not (Test-Path -LiteralPath $g)) {',
237
- ' & $git init $repo | Out-Null',
238
- '}',
239
- '& $git --git-dir=$g config core.longpaths true',
240
- '& $git --git-dir=$g config core.autocrlf false',
241
- '& $git --git-dir=$g config advice.addEmbeddedRepo false',
242
- "$attrDir = Join-Path $g 'info'",
243
- "Set-Content -LiteralPath (Join-Path $attrDir 'attributes') -Value '" + FIDELITY_ATTRS + "' -Encoding ascii",
244
- excludeSyncBlock(store.excludeFile, base),
245
- "$stamp = Join-Path $g 'gc.stamp'",
246
- "if (Test-Path -LiteralPath $stamp) { Write-Output ('GIT_OK ' + [String](Get-Content -LiteralPath $stamp -TotalCount 1 -ErrorAction SilentlyContinue)) } else { Write-Output 'GIT_OK' }"
247
- ].join('\n')
248
- }
249
-
250
- // 存量归一化迁移(issue #12):属性固化后,索引里的旧条目仍指向归一化
251
- // blob 的哈希——stat 缓存让裸 add -A 时序依赖地跳过重哈希(racy 复查只在
252
- // 「add 与文件同秒写入」时触发,实测不可靠),归一化残留会一直潜伏进新
253
- // 快照。--renormalize 对全部跟踪文件按当前属性(无转换)重哈希一次;
254
- // pathspec 时它是空操作(实测「Nothing specified, nothing added」),
255
- // 必须带 ':(top)' 顶层魔法 pathspec(cwd 无关),也不能加
256
- // --literal-pathspecs(会废掉魔法解析)。标记文件保证每仓库至多跑一次;
257
- // 失败(老 git 无该选项退出 129、偶发锁冲突等)只跳过标记、下条消息重试,
258
- // throw——迁移是 best-effort,不能让老 git 机器的快照从此全挂。
259
- // 依赖外层已定义的 $git/$g/$root;仅 snapshotScript 使用(execute 的救援
260
- // 安全快照在回退前必先跑一次快照,回退链路天然被覆盖)。
261
- function attrsMigrateBlock() {
262
- return [
263
- "$migStamp = Join-Path $g 'attrs-v1.stamp'",
264
- 'if (-not (Test-Path -LiteralPath $migStamp)) {',
265
- " & $git --git-dir=$g --work-tree=$root add --renormalize --ignore-errors -- ':(top)'",
266
- ' if ($LASTEXITCODE -le 1) { Set-Content -LiteralPath $migStamp -Value 1 -Encoding ascii -ErrorAction SilentlyContinue }',
267
- '}',
268
- ].join('\n')
269
- }
270
-
271
- // 快照:git add -A 增量同步 index(.gitignore/exclude 语义由 git 统一处理),
272
- // write-tree 生成树、commit-tree 生成无父孤儿提交、tag 保对象可达。
273
- // 不做 parent 链、不修剪:像 TraeWork 一样保留全量历史,tag 永远可查。
274
- // tag -f:事件重放/重发会产生重复 messageId,裸 tag 对已存在 tag fatal
275
- // 导致整条快照失败;commit-tree 每次生成新对象,-f 把 tag 指到最新提交,
276
- // 与「同一条消息重快照取最新状态」的语义一致。
277
- export function snapshotScript(root, store, gitExe, messageId, base) {
278
- return [
279
- "$ErrorActionPreference = 'Stop'",
280
- '$git = ' + psq(gitExe),
281
- '$g = ' + psq(store.git),
282
- '$root = ' + psq(root),
283
- heartbeatBlock(),
284
- dropGitlinksBlock(),
285
- excludeSyncBlock(store.excludeFile, base),
286
- attrsMigrateBlock(),
287
- // fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
288
- // (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
289
- // 快照缺个别路径可接受,好过整条快照 fatal。退出码 ≥2 才是真 fatal
290
- // (磁盘满、index.lock 等),必须显式 throw:pwsh 对原生命令非零退出
291
- // 不抛(EAP 不作用于 native),不检查就会带着未更新的旧索引走完
292
- // write-tree/commit/tag,产出「空树假成功」快照(实测 PS 5.1/pwsh 7
293
- // 均如此)。add 输出临时降到 Continue 再 2>&1 捕获:合并进管道会把
294
- // native stderr 包装成 ErrorRecord,EAP=Stop 下直接抛 NativeCommandError,
295
- // PS 5.1 的 SilentlyContinue 会把合并流里的记录整个丢弃(实测 LOG
296
- // 为空)——Continue 是两个版本下唯一都能拿到 stderr 文本的取值。捕获
297
- // 后按 "unable to index file 'X'" 提取被跳过的路径,以 SNAP_SKIP 行
298
- // 回传 JS 侧做用户可见提示。
299
- "$ErrorActionPreference = 'Continue'",
300
- "$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors 2>&1) | ForEach-Object { [string]$_ }) -join \"`n\"",
301
- '$addRc = $LASTEXITCODE',
302
- "$ErrorActionPreference = 'Stop'",
303
- 'if ($addRc -ge 2) { throw ("git add fatal (exit " + $addRc + "): " + $addLog) }',
304
- "foreach ($m in [regex]::Matches($addLog, \"unable to index file '([^']+)'\") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }",
305
- dropGitlinksBlock(),
306
- oversizeBlock(store.maxFileBytes),
307
- '$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
308
- "$commit = (& $git --git-dir=$g -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree $tree -m ('snapshot ' + " + psq(messageId) + ")).Trim()",
309
- '& $git --git-dir=$g tag -f ' + psq('snap-' + messageId) + ' $commit | Out-Null',
310
- // PF-1:TREE 行随 SNAP_OK 回传 add -A 之后的 index 树指纹——execute 用它与
311
- // preview 时的指纹比对即可判定「预览后文件是否变化」(STALE),免掉 execute
312
- // 侧整条重复 diff。安全快照(pre-rollback)同样输出,比对点见 routes-core。
313
- "Write-Output ('TREE ' + $tree)",
314
- "Write-Output 'SNAP_OK'"
315
- ].join('\n')
316
- }
317
-
318
- // diff:把当前状态 add index 后用 ls-files --stage 取当前清单,
319
- // 与目标 tag 的 ls-tree 对比——ignore/exclude 语义两侧一致,不会把
320
- // node_modules 等误报为“新增”。
321
- // 不用 -z:PowerShell 捕获原生命令输出会丢弃含 NUL 的行(实测整段变 null),
322
- // 改用 core.quotePath=false 让非 ASCII 路径原样输出,逐行按 TAB 解析。
323
- // 代价是文件名含换行的极端情况会解析错乱——概率可忽略,记录为已知限制。
324
- // (UTF-8 输出编码由 runShell 注入的 UTF8_PRELUDE 统一保证,此处不再重复设置。)
325
- // 输出协议(PF-1,JS 侧 parseDiffOutput 解析):
326
- // TOTAL <全量条数> / 前 maxChanges 条的 JSON / TREE <index 树指纹>
327
- // 全量 ConvertTo-Json PS 5.1 上慢且几万条目时 stdout 白胖——截断前移到
328
- // 脚本侧,total 语义由 TOTAL 行保持。write-tree 探测指纹(add 后的 index 树
329
- // 即工作区精确状态),preview 回传、execute 与安全快照比对判 STALE,省掉
330
- // execute 的整条重复 diff;无引用树对象由 prune/定期 gc 回收(每棵数百字节)。
331
- export function diffScript(root, store, gitExe, tag, base, maxChanges) {
332
- // 截断数由调用侧注入(snapshots.js MAX_CHANGES 单一事实源),模板不硬编码
333
- const take = Math.max(1, Math.trunc(Number(maxChanges) || 500))
334
- return [
335
- "$ErrorActionPreference = 'Stop'",
336
- '$git = ' + psq(gitExe),
337
- '$g = ' + psq(store.git),
338
- '$root = ' + psq(root),
339
- dropGitlinksBlock(),
340
- excludeSyncBlock(store.excludeFile, base),
341
- // fail-open add:语义同 snapshotScript(--ignore-errors 跳过无法索引的
342
- // 路径、≥2 显式 throw 防「旧索引假成功」);此处不提取 SNAP_SKIP——
343
- // 被跳过的路径不进索引,diff 天然不显示、rollback 的删除清单来自当前
344
- // 索引也天然不会误删它们
345
- '& $git --git-dir=$g --work-tree=$root add -A --ignore-errors',
346
- 'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
347
- dropGitlinksBlock(),
348
- oversizeBlock(store.maxFileBytes),
349
- '$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
350
- // tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
351
- // 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
352
- "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
353
- '$curMap = @{}',
354
- 'foreach ($r in @($curOut)) {',
355
- ' if (-not $r) { continue }',
356
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
357
- ' $sha = ($r.Substring(0, $tab) -split " ")[1]',
358
- ' $curMap[$path] = $sha',
359
- '}',
360
- '$targetMap = @{}',
361
- 'foreach ($r in @($targetOut)) {',
362
- ' if (-not $r) { continue }',
363
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
364
- ' $sha = ($r.Substring(0, $tab) -split " ")[2]',
365
- ' $targetMap[$path] = $sha',
366
- '}',
367
- '$result = @()',
368
- 'foreach ($k in $curMap.Keys) {',
369
- ' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" } }',
370
- ' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" } }',
371
- '}',
372
- 'foreach ($k in $targetMap.Keys) {',
373
- ' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
374
- '}',
375
- '$sorted = @($result | Sort-Object rel)',
376
- "Write-Output ('TOTAL ' + $sorted.Count)",
377
- 'Write-Output (ConvertTo-Json -InputObject @($sorted | Select-Object -First ' + take + ') -Depth 3 -Compress)',
378
- '$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
379
- "Write-Output ('TREE ' + $tree)"
380
- ].join('\n')
381
- }
382
-
383
- // 回退:恢复侧走 git archive --format=zip + Expand-Archive,删除侧按
384
- // 清单移除「当前有、目标无」的文件。曾尝试 tar 优先(bsdtar 性能更好),
385
- // 实测否决:System32\bsdtar 在 GBK 活动代码页(ACP=936)机器上把 tar
386
- // 流里的 UTF-8 文件名按 ANSI 解码——中文文件名解包成「璇存槑.txt」式的
387
- // 乱码新文件,原路径反而丢失;且 tar -m 才是必需的(stat 缓存碰撞),
388
- // Expand-Archive 天然把 mtime 设为解包时刻,zip 链路反而更稳。
389
- // 空树跳过 archive(空 zip 会让 Expand-Archive 报错),只执行删除。
390
- // 回退后保留快照 tag 与索引:git delta 空间便宜,保留历史可再次
391
- // 用该快照恢复(幂等),也避免误回退后无法找回。
392
- export function rollbackScript(root, store, gitExe, tag, base) {
393
- return [
394
- "$ErrorActionPreference = 'Stop'",
395
- '$git = ' + psq(gitExe),
396
- '$g = ' + psq(store.git),
397
- '$root = ' + psq(root),
398
- dropGitlinksBlock(),
399
- excludeSyncBlock(store.excludeFile, base),
400
- // fail-open add:语义同 snapshotScript 同款注释(diff/rollback 复用)
401
- '& $git --git-dir=$g --work-tree=$root add -A --ignore-errors',
402
- 'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
403
- dropGitlinksBlock(),
404
- oversizeBlock(store.maxFileBytes),
405
- // diffScript:-z NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
406
- '$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
407
- "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
408
- '$targetMap = @{}',
409
- 'foreach ($r in @($targetOut)) {',
410
- ' if (-not $r) { continue }',
411
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
412
- ' $targetMap[$path] = $true',
413
- '}',
414
- '$restored = $targetMap.Count',
415
- 'if ($restored -gt 0) {',
416
- ' $zip = ' + psq(store.dir + '\\restore-tmp.zip'),
417
- ' & $git --git-dir=$g archive --format=zip --output=$zip ' + psq(tag),
418
- ' Expand-Archive -LiteralPath $zip -DestinationPath $root -Force',
419
- ' Remove-Item -LiteralPath $zip -Force',
420
- '}',
421
- // 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop Remove-Item
422
- // 失败直接抛终止错误、pwsh 以非零码退出;POSIX rm set -e if
423
- // 条件里失败不会自动终止,须显式 exit 1(见 scripts.posix.js rollbackScript)。
424
- // 任何一侧半回退都不许报 ROLLBACK_OK——假成功会让救援永不触发(H1)。
425
- '$deleted = 0',
426
- 'foreach ($r in @($curOut)) {',
427
- ' if (-not $r) { continue }',
428
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
429
- ' if (-not $targetMap.ContainsKey($path)) {',
430
- " $full = Join-Path $root ($path.Replace('/','\\'))",
431
- ' if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Force; $deleted++ }',
432
- ' }',
433
- '}',
434
- "Write-Output ('ROLLBACK_OK ' + $deleted + ' ' + $restored)"
435
- ].join('\n')
436
- }
437
-
438
- // 回退失败救援(H1):rollback 脚本未输出 ROLLBACK_OK(工作区可能半回退)
439
- // 时,用 execute 预先打下的安全快照把工作区 reset 回「回退前」状态。
440
- // 入参 tag 是完整 tag 名(snap-pre-rollback-<ts>,snap- 前缀由调用侧
441
- // rescueRollback 拼齐——snapshotScript 打 tag 无条件加前缀,本模板保持
442
- // 通用只接受完整名)。命令与 rollbackScript 同款 --git-dir/--work-tree 形态;
443
- // pwsh 对 native 非零退出不抛(EAP 不作用于 native),必须显式查
444
- // $LASTEXITCODE throw,否则救援失败被静默吞掉、工作区停在半回退状态。
445
- export function rescueScript(root, store, gitExe, tag) {
446
- return [
447
- "$ErrorActionPreference = 'Stop'",
448
- '$git = ' + psq(gitExe),
449
- '$g = ' + psq(store.git),
450
- '$root = ' + psq(root),
451
- '& $git --git-dir=$g --work-tree=$root reset --hard ' + psq(tag),
452
- 'if ($LASTEXITCODE -ne 0) { throw ("git reset --hard failed (exit " + $LASTEXITCODE + ")") }',
453
- "Write-Output 'RESCUE_OK'"
454
- ].join('\n')
455
- }
456
-
457
- export function listTagsScript(store, gitExe) {
458
- return [
459
- "$ErrorActionPreference = 'Stop'",
460
- '$git = ' + psq(gitExe),
461
- '$g = ' + psq(store.git),
462
- // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
463
- // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
464
- 'if (-not (Test-Path -LiteralPath $g -PathType Container)) { exit 0 }',
465
- '& $git --git-dir=$g tag -l "snap-*"'
466
- ].join('\n')
467
- }
468
-
469
- // 孤儿重建用 tag 清单(带 creatordate):语义同 POSIX 版同名导出——
470
- // rebuildOrphans 据此恢复快照时间,time=0 会让管理列表时间前缀缺失、
471
- // retention/limits 按「最旧」误清。lightweight tag creatordate
472
- // 指向 commit 的提交日期。输出每行「<tag名> <秒级时间戳>」。
473
- export function listTagsWithTimeScript(store, gitExe) {
474
- return [
475
- "$ErrorActionPreference = 'Stop'",
476
- '$git = ' + psq(gitExe),
477
- '$g = ' + psq(store.git),
478
- 'if (-not (Test-Path -LiteralPath $g -PathType Container)) { exit 0 }',
479
- '& $git --git-dir=$g for-each-ref --format="%(refname:short) %(creatordate:unix)" "refs/tags/snap-*"'
480
- ].join('\n')
481
- }
482
-
483
- // 定期 gc:全量保留策略下对象只增不减,且默认 loose 存储(每对象一个
484
- // 小文件,NTFS 最小簇 4KB)非常浪费;gc 压 pack + 跨版本 delta 通常省一半
485
- // 以上。--prune=now 让「会话删除联动清理」删掉的 tag 立即真正释放空间
486
- // (默认 2 周宽限期内对象仍占盘)——安全前提是 gc 与快照在同一条串行
487
- // 队列里执行(见 maintenance.js),不存在并发竞态。
488
- // 结尾写 gc.stamp:跨重启的节流凭据(ensureGit 回读)。
489
- export function gcScript(store, gitExe) {
490
- return [
491
- "$ErrorActionPreference = 'Stop'",
492
- '$git = ' + psq(gitExe),
493
- '$g = ' + psq(store.git),
494
- '& $git --git-dir=$g gc --quiet --prune=now',
495
- "Set-Content -LiteralPath (Join-Path $g 'gc.stamp') -Value ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) -Encoding ascii",
496
- "Write-Output 'GC_OK'"
497
- ].join('\n')
498
- }
499
-
500
- // 快照失败后的残骸清理(issue #7 实测:失败重试一个下午可积累 127GB
501
- // dangling 对象)。失败的 add 已把部分 blob 写进对象库,但 write-tree/
502
- // commit/tag 未发生——这些对象无引用可达;git prune 以 refs + 暂存 index
503
- // 为根做可达性删除,正好只清掉这批无主对象,不碰任何 tag 快照。不做 gc:
504
- // gc 是全量 repack 重活,失败重试场景下对象库往往已被残骸撑大,代价过高。
505
- // 调用点在 captureSnapshot 的 catch 里,与快照同走一条串行队列,无锁竞态。
506
- export function pruneScript(store, gitExe) {
507
- return [
508
- "$ErrorActionPreference = 'Stop'",
509
- '$git = ' + psq(gitExe),
510
- '$g = ' + psq(store.git),
511
- '& $git --git-dir=$g prune',
512
- "Write-Output 'PRUNE_OK'"
513
- ].join('\n')
514
- }
515
-
516
- // 失败后的孤儿进程清扫 + stale 锁清理(issue #7 实测:超时被杀的 shell 留下
517
- // 孤儿 git 继续持有 index.lock 30+ 分钟)。M3 三级出口(语义与动机见
518
- // scripts.posix.js 同名函数注释):另一活实例心跳让路(CLEANUP_OTHER_INSTANCE)
519
- // → 新锁让路(CLEANUP_SKIPPED_FRESH_LOCK)→ 原有清扫(CLEANUP_DONE)。
520
- // DSH 的 subprocess 服务本身已做树级终止(taskkill /T /F),这里是竞态窗口
521
- // 与旧版本漏网的兜底:按「命令行含 --git-dir=<本仓库>」定位孤儿——该标记只
522
- // 出现在本插件派生的 git 进程参数里,编辑器等无关进程不会命中。
523
- // 全程 SilentlyContinue + best-effort:调用点在 runShell 的失败路径上,
524
- // 清扫自身再抛错只会掩盖原始错误。首行哨兵注释供 runShell 识别本脚本、
525
- // 防止「清扫失败 → 再清扫」的递归。
526
- export function killOrphansScript(gitDir) {
527
- return [
528
- '# RECALL_CLEANUP',
529
- "$ErrorActionPreference = 'SilentlyContinue'",
530
- '$g = ' + psq(gitDir),
531
- // —— 第 1 级保护:另一活实例心跳(store.dir 是 git-dir 上两级)——
532
- "$hbf = Join-Path (Split-Path -Parent (Split-Path -Parent $g)) 'heartbeat'",
533
- 'if (Test-Path -LiteralPath $hbf -PathType Leaf) {',
534
- ' $hl = Get-Content -LiteralPath $hbf -TotalCount 1',
535
- ' if ($hl) {',
536
- " $hp = ('' + $hl).Trim() -split '\\s+'",
537
- ' $a = [int64]0; $b = [int64]0',
538
- ' if (($hp.Count -ge 2) -and [int64]::TryParse($hp[0], [ref]$a) -and [int64]::TryParse($hp[1], [ref]$b)) {',
539
- ' $age = [DateTimeOffset]::Now.ToUnixTimeSeconds() - $b',
540
- ' if (($a -gt 0) -and ($a -ne ' + String(process.pid) + ') -and ($age -ge 0) -and ($age -lt ' + HEARTBEAT_TTL_S + ')) {',
541
- ' if (Get-Process -Id $a -ErrorAction SilentlyContinue) {',
542
- " Write-Output ('CLEANUP_OTHER_INSTANCE ' + $a)",
543
- ' exit 0',
544
- ' }',
545
- ' }',
546
- ' }',
547
- ' }',
548
- '}',
549
- // —— 第 2 级保护:新锁(有 git 操作可能正在进行)——
550
- "$cutoff = (Get-Date).AddMinutes(-" + STALE_LOCK_MIN + ')',
551
- '$fresh = $false',
552
- "foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
553
- ' $lp = Join-Path $g $n',
554
- ' if (Test-Path -LiteralPath $lp -PathType Leaf) {',
555
- ' if ((Get-Item -LiteralPath $lp).LastWriteTime -gt $cutoff) { $fresh = $true; break }',
556
- ' }',
557
- '}',
558
- 'if (-not $fresh) {',
559
- " $fl = @(Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $cutoff })",
560
- ' if ($fl.Count -gt 0) { $fresh = $true }',
561
- '}',
562
- 'if ($fresh) {',
563
- " Write-Output 'CLEANUP_SKIPPED_FRESH_LOCK'",
564
- ' exit 0',
565
- '}',
566
- // —— 保护未命中:原有清扫(杀孤儿 + 清 stale 锁)——
567
- // 标记用变量拼接而非字面量:本脚本进程的命令行(-Command 全文)只有
568
- // 未展开的 '$g',Where-Object 不会匹配到自己
569
- "$marker = '--git-dir=' + $g",
570
- "Get-CimInstance Win32_Process -Filter 'CommandLine IS NOT NULL' | Where-Object { $_.CommandLine.Contains($marker) } | ForEach-Object {",
571
- ' & taskkill /T /F /PID $_.ProcessId | Out-Null',
572
- '}',
573
- // 锁清单:index.lock 是 add/checkout 的持久锁,其余是 gc/tag/pack 链路
574
- // 可能残留的;refs 下的 per-ref 锁用递归兜底(能走到这里说明锁已陈旧)
575
- "foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
576
- ' Remove-Item -LiteralPath (Join-Path $g $n) -Force',
577
- '}',
578
- "Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' | Remove-Item -Force",
579
- "Write-Output 'CLEANUP_DONE'"
580
- ].join('\n')
581
- }
582
-
583
- // 删除指定快照 tag(会话已删联动清理用)。best-effort:个别 tag 已不存在时
584
- // git 非零退出,但其余 tag 已被删除——所以显式 exit 0 吞掉退出码,
585
- // 残留的由下一次清理幂等地收尾;JS 侧无论脚本结果都会同步索引。
586
- export function purgeTagsScript(store, gitExe, tags) {
587
- return [
588
- "$ErrorActionPreference = 'Stop'",
589
- '$git = ' + psq(gitExe),
590
- '$g = ' + psq(store.git),
591
- '& $git --git-dir=$g tag -d ' + tags.map((t) => psq(t)).join(' '),
592
- "Write-Output 'PURGE_DONE'",
593
- 'exit 0'
594
- ].join('\n')
595
- }
596
-
597
- // 任意长度文本写入(index.json / exclude.txt / lineage.json / root.txt 共用,
598
- // PF-2):stdin 传全文 + 单进程落盘——旧实现按 base64 20000 字符分块内联
599
- // (规避 -Command 单 argv 元素的 32767 上限),每块一条 PowerShell 进程,
600
- // 索引几百条时 saveIndex = 6+ 条进程,而它在每条消息快照后、每次删除、每次
601
- // init 都全量重写。stdin 不经命令行,长度上限与编码坑天然消失。
602
- // 读取手法是探针(tests/probe/stdin-write.test.js,2026-08-29)钉死的形态:
603
- // [Console]::In.ReadToEnd() 在 PS 5.1 按输入代码页(中文机器 GBK)解码
604
- // UTF-8 字节必挂——必须走 OpenStandardInput 读原始字节再显式 UTF8 解码,
605
- // 与代码页无关;落盘必须用 .NET WriteAllText 无 BOM 重载(PS 5.1 的
606
- // Set-Content -Encoding utf8 必带 BOM)。目录创建归调用方(writeExclude 的
607
- // mkdirScript 兜底 / index.json 的父目录在建仓时已存在)。
608
- export function fileWriteStdinCmd(file) {
609
- return [
610
- "$ErrorActionPreference = 'Stop'",
611
- '$tmp = ' + psq(file),
612
- '$stream = [Console]::OpenStandardInput()',
613
- '$ms = New-Object System.IO.MemoryStream',
614
- '$buf = New-Object byte[] 8192',
615
- 'while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) { $ms.Write($buf, 0, $n) }',
616
- '$text = [Text.UTF8Encoding]::new($false).GetString($ms.ToArray())',
617
- '[IO.File]::WriteAllText($tmp, $text, [Text.UTF8Encoding]::new($false))'
618
- ].join('\n')
619
- }
620
-
621
- // 原子 rename(H2):同卷 move 是 O(1) 元数据操作,把「已完整写完的 tmp」
622
- // 一步替换成目标文件,杜绝分块写中途崩溃留下的截断 JSON;也用于 loadIndex
623
- // 把损坏索引改名 .corrupt-<ts> 保留现场(见 snapshots.js quarantineCorruptIndex)。
624
- export function renameFileCmd(src, dst) {
625
- return "$ErrorActionPreference = 'Stop'\nMove-Item -Force -LiteralPath " + psq(src) + ' -Destination ' + psq(dst)
626
- }
627
-
628
- // 显式 -Encoding UTF8:写侧(writeTextViaShell base64 解码落盘)产出的是
629
- // 无 BOM UTF-8,PS 7 默认即按 UTF-8 读,但 PS 5.1 兜底(pwsh-local 解析链
630
- // 降级)按 ANSI 活动代码页解码——中文 root 乱码 → JSON.parse 失败 → 误走
631
- // H2 隔离分支。与 excludeReadCmd 等同文件其他读取处的既有写法对齐。
632
- export function indexReadCmd(dir) {
633
- return 'Get-Content -LiteralPath ' + psq(dir + '\\index.json') + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
634
- }
635
-
636
- // fork lineage 读取(F1):lineage.json 记录 childId↔parentId 撤回链,
637
- // 与 index.json 同层、原子写(writeTextViaShell)。文件不存在时输出空串。
638
- export function lineageReadCmd(dir) {
639
- return 'Get-Content -LiteralPath ' + psq(dir + '\\lineage.json') + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
640
- }
641
-
642
- // 旧版项目内 blobs 目录清理(仅 home 存储可用时调用,见 store.js cleanupLegacy)。
643
- // -ErrorAction SilentlyContinue(PF-5):目标不存在是常态(极早期版本才有),
644
- // 不容错的话 Remove-Item 抛错 → cleanupLegacy 永远走不到「成功」分支,
645
- // legacyCleaned 标记失效,每次 init 都白跑一条进程。
646
- export function legacyRmScript(path) {
647
- return 'Remove-Item -Recurse -Force -LiteralPath ' + psq(path) + ' -ErrorAction SilentlyContinue'
648
- }
649
-
650
- // exclude.txt 原文读取(设置页编辑用):-Raw 保留换行与空行结构,让用户
651
- // 看到的就是落盘原文;文件不存在时 SilentlyContinue 输出空串,JS 侧按
652
- // 「尚未配置」处理——设置页在快照存储刚建好、exclude.txt 还没写过时也会打开。
653
- export function excludeReadCmd(file) {
654
- return 'Get-Content -LiteralPath ' + psq(file) + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
655
- }
656
-
657
- // 批量读全部 exclude 文件(PF-8,一条脚本替代每文件一条进程——设置页
658
- // 排除配置首开 4-6 条进程链里的大头)。内容按 base64 单行输出:exclude.txt
659
- // 是用户可编辑的任意文本(可含空行/注释/任意字符串),逐行定界会被内容
660
- // 行打乱,base64 天然免疫;也顺带规避 PS 5.1 下中文内容的代码页转码。
661
- // 文件不存在输出空段(JS 侧按「尚未配置」处理,与 excludeReadCmd 一致)。
662
- // 输出协议(JS 侧 parseExcludeDump 解析):
663
- // EXCLBEGIN <文件路径> / <base64 单行,可为空> / EXCLEND
664
- export function excludeDumpScript(files) {
665
- const lines = ["$ErrorActionPreference = 'SilentlyContinue'"]
666
- for (const f of files || []) {
667
- const q = psq(f)
668
- lines.push(
669
- "Write-Output ('EXCLBEGIN ' + " + q + ')',
670
- 'if (Test-Path -LiteralPath ' + q + ' -PathType Leaf) { Write-Output ([Convert]::ToBase64String([IO.File]::ReadAllBytes(' + q + '))) }',
671
- "Write-Output 'EXCLEND'"
672
- )
673
- }
674
- return lines.join('\n')
675
- }
676
-
677
- // 目录存在探测:输出定长 YES/NO 标记(与 posix 版逐字同语义),
678
- // JS 侧统一按 'YES' 判定,不依赖退出码——runShell 对非零退出直接抛错。
679
- export function dirExistsScript(dir) {
680
- return "if (Test-Path -LiteralPath " + psq(dir) + " -PathType Container) { Write-Output 'YES' } else { Write-Output 'NO' }"
681
- }
682
-
683
- // 影子仓库磁盘占用(设置页快照管理卡片用):git 自带的 count-objects -v
684
- // 输出含 size-pack(KiB 单位,pack 文件总大小),足够向用户展示量级,
685
- // 不需要逐文件累加的慢扫描。
686
- export function countObjectsScript(store, gitExe) {
687
- return [
688
- '$git = ' + psq(gitExe),
689
- '$g = ' + psq(store.git),
690
- '& $git --git-dir=$g count-objects -v'
691
- ].join('\n')
692
- }
693
-
694
- // 目录总大小(字节):.NET 手动栈遍历(PF-3,容错语义同 oversizeBlock——
695
- // 逐目录 try/catch 跳过不可访问目录)替代 Get-ChildItem -Recurse 逐文件
696
- // 管道求和,GB 级快照库从秒级降到亚秒。$sum 初始化 [long]0 防溢出,目录
697
- // 为空/全跳过时输出 0(旧实现的 .Sum 为 null,JS 侧 parseInt||0 兜底等价)。
698
- export function diskUsageScript(dir) {
699
- const q = psq(dir)
700
- return [
701
- '$usageStack = [System.Collections.Generic.Stack[string]]::new()',
702
- '$usageStack.Push(' + q + ')',
703
- '$sum = [long]0',
704
- 'while ($usageStack.Count -gt 0) {',
705
- ' $dir = $usageStack.Pop()',
706
- ' try {',
707
- ' $di = [System.IO.DirectoryInfo]::new($dir)',
708
- ' foreach ($f in $di.EnumerateFiles()) { $sum += $f.Length }',
709
- ' foreach ($d in $di.EnumerateDirectories()) { $usageStack.Push($d.FullName) }',
710
- ' } catch {}',
711
- '}',
712
- 'Write-Output $sum'
713
- ].join('\n')
714
- }
715
-
716
- // 列目录下所有一级子目录全路径:manage/list 枚举 home 容器下的所有
717
- // 哈希子目录用(每个子目录是一个工作区的 store)。SilentlyContinue
718
- // 容忍个别不可读条目。输出每行一个全路径,JS 侧按换行拆分。
719
- export function listSubdirsScript(dir) {
720
- return "Get-ChildItem -LiteralPath " + psq(dir) + " -Directory -ErrorAction SilentlyContinue | ForEach-Object { Write-Output $_.FullName }"
721
- }
722
-
723
- // 批量 dump 全部 store 的元数据:一条 shell 拿「容器下所有子目录 +
724
- // 额外降级目录」的 root.txt、index.json 与 lineage.json 全文。为什么批量:
725
- // 旧实现每个目录 2 条 shell(读 index + 读 root.txt)串行跑,20 个目录就是
726
- // 40 次 PowerShell 冷启动(每次 0.3-1s)——快照管理列表 20 秒级慢的
727
- // 根因。PF-4 起段内再带 lineage.json(manage lineage 原本对每个 root 串行
728
- // 一条进程,并入后零新增)。输出定界格式(JS 侧 parseStoresDump 状态机解析):
729
- // ==DIR <目录>
730
- // ROOT <工作区路径或空>
731
- // INDEXBEGIN / index.json 原文 / INDEXEND
732
- // LINEAGEBEGIN / lineage.json 原文 / LINEAGEEND
733
- // 标记行不会与内容混淆:root 路径不含换行(Windows 非法字符),JSON
734
- // 单行以 [ 或 { 起头、内部路径同样不含换行。
735
- export function storesDumpScript(container, extraDirs) {
736
- const lines = [
737
- "$ErrorActionPreference = 'Stop'",
738
- '$dirs = @()'
739
- ]
740
- if (container) {
741
- lines.push('$base = ' + psq(container))
742
- lines.push("$dirs += @(Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })")
743
- }
744
- for (const d of extraDirs || []) lines.push('$dirs += ' + psq(d))
745
- lines.push(
746
- 'foreach ($d in $dirs) {',
747
- ' if (-not $d) { continue }',
748
- ' if (-not (Test-Path -LiteralPath $d -PathType Container)) { continue }',
749
- ' Write-Output ("==DIR " + $d)',
750
- " $rt = Join-Path $d 'root.txt'",
751
- " if (Test-Path -LiteralPath $rt -PathType Leaf) {",
752
- " $rv = (Get-Content -LiteralPath $rt -Raw -Encoding UTF8 -ErrorAction SilentlyContinue)",
753
- // 先规整成单行再拼接:root 是单行路径,但用户手改文件可能带 CRLF,
754
- // 直接拼会把标记结构打乱
755
- " if ($rv) { $rv = $rv.Trim() }",
756
- " Write-Output ('ROOT ' + $rv)",
757
- ' } else {',
758
- " Write-Output 'ROOT '",
759
- ' }',
760
- " Write-Output 'INDEXBEGIN'",
761
- " $ix = Join-Path $d 'index.json'",
762
- " if (Test-Path -LiteralPath $ix -PathType Leaf) {",
763
- ' $j = Get-Content -LiteralPath $ix -Raw -Encoding UTF8 -ErrorAction SilentlyContinue',
764
- ' if ($j) { Write-Output $j.TrimEnd() }',
765
- ' }',
766
- " Write-Output 'INDEXEND'",
767
- " Write-Output 'LINEAGEBEGIN'",
768
- " $lg = Join-Path $d 'lineage.json'",
769
- " if (Test-Path -LiteralPath $lg -PathType Leaf) {",
770
- ' $l = Get-Content -LiteralPath $lg -Raw -Encoding UTF8 -ErrorAction SilentlyContinue',
771
- ' if ($l) { Write-Output $l.TrimEnd() }',
772
- ' }',
773
- " Write-Output 'LINEAGEEND'",
774
- '}',
775
- 'exit 0'
776
- )
777
- return lines.join('\n')
778
- }
1
+ function psq(value) {
2
+ return "'" + String(value).replace(/'/g, "''") + "'";
3
+ }
4
+ const UTF8_PRELUDE = "$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}";
5
+ const MAX_FILE_BYTES = 104857600;
6
+ const STALE_LOCK_MIN = 5;
7
+ const HEARTBEAT_TTL_S = 900;
8
+ const FIDELITY_ATTRS = "* -text -filter -ident -export-ignore -export-subst -working-tree-encoding";
9
+ function stripBom(text) {
10
+ return text.replace(/^\uFEFF/, "");
11
+ }
12
+ function dropGitlinksBlock() {
13
+ return [
14
+ "& $git --git-dir=$g ls-files --stage | Where-Object { $_ -like '160000*' } | ForEach-Object {",
15
+ ' $p = ($_ -split "`t")[1]',
16
+ " & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $p",
17
+ "}"
18
+ ].join("\n");
19
+ }
20
+ function oversizeBlock(maxBytes) {
21
+ return [
22
+ "$oversizeStack = [System.Collections.Generic.Stack[string]]::new()",
23
+ "$oversizeStack.Push($root)",
24
+ "$oversizeRel = [System.Collections.Generic.List[string]]::new()",
25
+ "while ($oversizeStack.Count -gt 0) {",
26
+ " $dir = $oversizeStack.Pop()",
27
+ " try {",
28
+ " $di = [System.IO.DirectoryInfo]::new($dir)",
29
+ " foreach ($f in $di.EnumerateFiles()) {",
30
+ " if ($f.Length -gt " + String(maxBytes || MAX_FILE_BYTES) + ") {",
31
+ " $oversizeRel.Add($f.FullName.Substring($root.Length + 1).Replace('\\','/'))",
32
+ " }",
33
+ " }",
34
+ " foreach ($d in $di.EnumerateDirectories()) { $oversizeStack.Push($d.FullName) }",
35
+ " } catch {}",
36
+ "}",
37
+ "for ($i = 0; $i -lt $oversizeRel.Count; $i += 100) {",
38
+ " $batch = $oversizeRel.GetRange($i, [Math]::Min(100, $oversizeRel.Count - $i))",
39
+ " & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch",
40
+ "}"
41
+ ].join("\n");
42
+ }
43
+ function excludeSyncBlock(excludeFile, base) {
44
+ const baseList = (Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"]).map(psq).join(",");
45
+ return [
46
+ "$exFile = " + psq(excludeFile),
47
+ "$userPats = @()",
48
+ "if (Test-Path -LiteralPath $exFile) { $userPats = @(Get-Content -LiteralPath $exFile -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $t = $_.Trim(); $t -and -not $t.StartsWith('#') }) }",
49
+ "$lines = @('') + @(" + baseList + ") + $userPats",
50
+ "$exc = Join-Path $g 'info\\exclude'",
51
+ "$excOld = @(Get-Content -LiteralPath $exc -Encoding UTF8 -ErrorAction SilentlyContinue)",
52
+ "$same = ($excOld.Count -eq $lines.Count)",
53
+ "if ($same) {",
54
+ " for ($i = 0; $i -lt $lines.Count; $i++) {",
55
+ " if ($excOld[$i] -ne $lines[$i]) { $same = $false; break }",
56
+ " }",
57
+ "}",
58
+ "if (-not $same) {",
59
+ " Set-Content -LiteralPath $exc -Value $lines -Encoding utf8",
60
+ " $hit = @(& $git -c core.quotePath=false --literal-pathspecs --git-dir=$g ls-files -i -c --exclude-from=$exc | Where-Object { $_ })",
61
+ " for ($i = 0; $i -lt $hit.Count; $i += 100) {",
62
+ " $batch = @($hit[$i..([Math]::Min($i + 99, $hit.Count - 1))])",
63
+ " & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch",
64
+ " }",
65
+ "}"
66
+ ].join("\n");
67
+ }
68
+ function heartbeatBlock() {
69
+ return [
70
+ "$hbf = Join-Path (Split-Path -Parent (Split-Path -Parent $g)) 'heartbeat'",
71
+ "Set-Content -LiteralPath $hbf -Value ('" + String(process.pid) + " ' + [DateTimeOffset]::Now.ToUnixTimeSeconds()) -Encoding ascii -ErrorAction SilentlyContinue"
72
+ ].join("\n");
73
+ }
74
+ function resolveGitScript() {
75
+ return [
76
+ "$candidates = @()",
77
+ "$g = (Get-Command git -ErrorAction SilentlyContinue).Source",
78
+ "if ($g) { $candidates += $g }",
79
+ "if (${env:ProgramFiles}) { $candidates += (Join-Path ${env:ProgramFiles} 'Git\\cmd\\git.exe') }",
80
+ "if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'Git\\cmd\\git.exe') }",
81
+ "if (${env:LocalAppData}) { $candidates += (Join-Path ${env:LocalAppData} 'Programs\\Git\\cmd\\git.exe') }",
82
+ "$g = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1",
83
+ "if ($g) { Write-Output $g }"
84
+ ].join("\n");
85
+ }
86
+ function homeDirScript(root, envHome) {
87
+ return [
88
+ "$r = " + psq(root),
89
+ "$h = if ($env:DSH_HOME) { $env:DSH_HOME } elseif (" + psq(envHome) + ") { " + psq(envHome) + ' } else { Join-Path $env:USERPROFILE ".dsh" }',
90
+ "$sha = [Security.Cryptography.SHA256]::Create()",
91
+ "$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
92
+ "Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
93
+ ].join("\n");
94
+ }
95
+ function mkdirScript(dir) {
96
+ return "New-Item -ItemType Directory -Force -Path " + psq(dir) + " | Out-Null";
97
+ }
98
+ function migrateScript(src, dst) {
99
+ return [
100
+ "$ErrorActionPreference = 'Stop'",
101
+ "$src = " + psq(src),
102
+ "$dst = " + psq(dst),
103
+ "if (Test-Path -LiteralPath (Join-Path $src 'git')) { Move-Item -LiteralPath (Join-Path $src 'git') -Destination (Join-Path $dst 'git') -Force }",
104
+ "if (Test-Path -LiteralPath (Join-Path $src 'index.json')) { Move-Item -LiteralPath (Join-Path $src 'index.json') -Destination (Join-Path $dst 'index.json') -Force }",
105
+ "Remove-Item -Recurse -Force -LiteralPath $src -ErrorAction SilentlyContinue",
106
+ "Write-Output 'MIGRATE_OK'"
107
+ ].join("\n");
108
+ }
109
+ function ensureGitScript(store, gitExe, base) {
110
+ return [
111
+ "$ErrorActionPreference = 'Stop'",
112
+ "$git = " + psq(gitExe),
113
+ "$repo = " + psq(store.repo),
114
+ "$g = " + psq(store.git),
115
+ heartbeatBlock(),
116
+ "if (-not (Test-Path -LiteralPath $g)) {",
117
+ " & $git init $repo | Out-Null",
118
+ "}",
119
+ "& $git --git-dir=$g config core.longpaths true",
120
+ "& $git --git-dir=$g config core.autocrlf false",
121
+ "& $git --git-dir=$g config advice.addEmbeddedRepo false",
122
+ "$attrDir = Join-Path $g 'info'",
123
+ "Set-Content -LiteralPath (Join-Path $attrDir 'attributes') -Value '" + FIDELITY_ATTRS + "' -Encoding ascii",
124
+ excludeSyncBlock(store.excludeFile, base),
125
+ "$stamp = Join-Path $g 'gc.stamp'",
126
+ "if (Test-Path -LiteralPath $stamp) { Write-Output ('GIT_OK ' + [String](Get-Content -LiteralPath $stamp -TotalCount 1 -ErrorAction SilentlyContinue)) } else { Write-Output 'GIT_OK' }"
127
+ ].join("\n");
128
+ }
129
+ function attrsMigrateBlock() {
130
+ return [
131
+ "$migStamp = Join-Path $g 'attrs-v1.stamp'",
132
+ "if (-not (Test-Path -LiteralPath $migStamp)) {",
133
+ " & $git --git-dir=$g --work-tree=$root add --renormalize --ignore-errors -- ':(top)'",
134
+ " if ($LASTEXITCODE -le 1) { Set-Content -LiteralPath $migStamp -Value 1 -Encoding ascii -ErrorAction SilentlyContinue }",
135
+ "}"
136
+ ].join("\n");
137
+ }
138
+ function snapshotScript(root, store, gitExe, messageId, base) {
139
+ return [
140
+ "$ErrorActionPreference = 'Stop'",
141
+ "$git = " + psq(gitExe),
142
+ "$g = " + psq(store.git),
143
+ "$root = " + psq(root),
144
+ heartbeatBlock(),
145
+ dropGitlinksBlock(),
146
+ excludeSyncBlock(store.excludeFile, base),
147
+ attrsMigrateBlock(),
148
+ // fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
149
+ // (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
150
+ // 快照缺个别路径可接受,好过整条快照 fatal。退出码 ≥2 才是真 fatal
151
+ // (磁盘满、index.lock 等),必须显式 throw:pwsh 对原生命令非零退出
152
+ // 不抛(EAP 不作用于 native),不检查就会带着未更新的旧索引走完
153
+ // write-tree/commit/tag,产出「空树假成功」快照(实测 PS 5.1/pwsh 7
154
+ // 均如此)。add 输出临时降到 Continue 再 2>&1 捕获:合并进管道会把
155
+ // native stderr 包装成 ErrorRecord,EAP=Stop 下直接抛 NativeCommandError,
156
+ // 而 PS 5.1 的 SilentlyContinue 会把合并流里的记录整个丢弃(实测 LOG
157
+ // 为空)——Continue 是两个版本下唯一都能拿到 stderr 文本的取值。捕获
158
+ // 后按 "unable to index file 'X'" 提取被跳过的路径,以 SNAP_SKIP 行
159
+ // 回传 JS 侧做用户可见提示。
160
+ "$ErrorActionPreference = 'Continue'",
161
+ '$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors 2>&1) | ForEach-Object { [string]$_ }) -join "`n"',
162
+ "$addRc = $LASTEXITCODE",
163
+ "$ErrorActionPreference = 'Stop'",
164
+ 'if ($addRc -ge 2) { throw ("git add fatal (exit " + $addRc + "): " + $addLog) }',
165
+ `foreach ($m in [regex]::Matches($addLog, "unable to index file '([^']+)'") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }`,
166
+ dropGitlinksBlock(),
167
+ oversizeBlock(store.maxFileBytes),
168
+ "$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()",
169
+ "$commit = (& $git --git-dir=$g -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree $tree -m ('snapshot ' + " + psq(messageId) + ")).Trim()",
170
+ "& $git --git-dir=$g tag -f " + psq("snap-" + messageId) + " $commit | Out-Null",
171
+ // PF-1:TREE 行随 SNAP_OK 回传 add -A 之后的 index 树指纹——execute 用它与
172
+ // preview 时的指纹比对即可判定「预览后文件是否变化」(STALE),免掉 execute
173
+ // 侧整条重复 diff。安全快照(pre-rollback)同样输出,比对点见 routes-core。
174
+ "Write-Output ('TREE ' + $tree)",
175
+ "Write-Output 'SNAP_OK'"
176
+ ].join("\n");
177
+ }
178
+ function diffScript(root, store, gitExe, tag, base, maxChanges) {
179
+ const take = Math.max(1, Math.trunc(Number(maxChanges) || 500));
180
+ return [
181
+ "$ErrorActionPreference = 'Stop'",
182
+ "$git = " + psq(gitExe),
183
+ "$g = " + psq(store.git),
184
+ "$root = " + psq(root),
185
+ dropGitlinksBlock(),
186
+ excludeSyncBlock(store.excludeFile, base),
187
+ // fail-open add:语义同 snapshotScript(--ignore-errors 跳过无法索引的
188
+ // 路径、≥2 显式 throw 防「旧索引假成功」);此处不提取 SNAP_SKIP——
189
+ // 被跳过的路径不进索引,diff 天然不显示、rollback 的删除清单来自当前
190
+ // 索引也天然不会误删它们
191
+ "& $git --git-dir=$g --work-tree=$root add -A --ignore-errors",
192
+ 'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
193
+ dropGitlinksBlock(),
194
+ oversizeBlock(store.maxFileBytes),
195
+ "$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage",
196
+ // tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
197
+ // 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
198
+ "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
199
+ "$curMap = @{}",
200
+ "foreach ($r in @($curOut)) {",
201
+ " if (-not $r) { continue }",
202
+ ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
203
+ ' $sha = ($r.Substring(0, $tab) -split " ")[1]',
204
+ " $curMap[$path] = $sha",
205
+ "}",
206
+ "$targetMap = @{}",
207
+ "foreach ($r in @($targetOut)) {",
208
+ " if (-not $r) { continue }",
209
+ ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
210
+ ' $sha = ($r.Substring(0, $tab) -split " ")[2]',
211
+ " $targetMap[$path] = $sha",
212
+ "}",
213
+ "$result = @()",
214
+ "foreach ($k in $curMap.Keys) {",
215
+ ' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" } }',
216
+ ' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" } }',
217
+ "}",
218
+ "foreach ($k in $targetMap.Keys) {",
219
+ ' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
220
+ "}",
221
+ "$sorted = @($result | Sort-Object rel)",
222
+ "Write-Output ('TOTAL ' + $sorted.Count)",
223
+ "Write-Output (ConvertTo-Json -InputObject @($sorted | Select-Object -First " + take + ") -Depth 3 -Compress)",
224
+ "$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()",
225
+ "Write-Output ('TREE ' + $tree)"
226
+ ].join("\n");
227
+ }
228
+ function rollbackScript(root, store, gitExe, tag, base) {
229
+ return [
230
+ "$ErrorActionPreference = 'Stop'",
231
+ "$git = " + psq(gitExe),
232
+ "$g = " + psq(store.git),
233
+ "$root = " + psq(root),
234
+ dropGitlinksBlock(),
235
+ excludeSyncBlock(store.excludeFile, base),
236
+ // fail-open add:语义同 snapshotScript 同款注释(diff/rollback 复用)
237
+ "& $git --git-dir=$g --work-tree=$root add -A --ignore-errors",
238
+ 'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
239
+ dropGitlinksBlock(),
240
+ oversizeBlock(store.maxFileBytes),
241
+ // diffScript:-z NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
242
+ "$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage",
243
+ "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
244
+ "$targetMap = @{}",
245
+ "foreach ($r in @($targetOut)) {",
246
+ " if (-not $r) { continue }",
247
+ ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
248
+ " $targetMap[$path] = $true",
249
+ "}",
250
+ "$restored = $targetMap.Count",
251
+ "if ($restored -gt 0) {",
252
+ " $zip = " + psq(store.dir + "\\restore-tmp.zip"),
253
+ " & $git --git-dir=$g archive --format=zip --output=$zip " + psq(tag),
254
+ " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
255
+ " Remove-Item -LiteralPath $zip -Force",
256
+ "}",
257
+ // 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下 Remove-Item
258
+ // 失败直接抛终止错误、pwsh 以非零码退出;POSIX 侧 rm 在 set -e if
259
+ // 条件里失败不会自动终止,须显式 exit 1(见 scripts.posix.js rollbackScript)。
260
+ // 任何一侧半回退都不许报 ROLLBACK_OK——假成功会让救援永不触发(H1)。
261
+ "$deleted = 0",
262
+ "foreach ($r in @($curOut)) {",
263
+ " if (-not $r) { continue }",
264
+ ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
265
+ " if (-not $targetMap.ContainsKey($path)) {",
266
+ " $full = Join-Path $root ($path.Replace('/','\\'))",
267
+ " if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Force; $deleted++ }",
268
+ " }",
269
+ "}",
270
+ "Write-Output ('ROLLBACK_OK ' + $deleted + ' ' + $restored)"
271
+ ].join("\n");
272
+ }
273
+ function rescueScript(root, store, gitExe, tag) {
274
+ return [
275
+ "$ErrorActionPreference = 'Stop'",
276
+ "$git = " + psq(gitExe),
277
+ "$g = " + psq(store.git),
278
+ "$root = " + psq(root),
279
+ "& $git --git-dir=$g --work-tree=$root reset --hard " + psq(tag),
280
+ 'if ($LASTEXITCODE -ne 0) { throw ("git reset --hard failed (exit " + $LASTEXITCODE + ")") }',
281
+ "Write-Output 'RESCUE_OK'"
282
+ ].join("\n");
283
+ }
284
+ function listTagsScript(store, gitExe) {
285
+ return [
286
+ "$ErrorActionPreference = 'Stop'",
287
+ "$git = " + psq(gitExe),
288
+ "$g = " + psq(store.git),
289
+ // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
290
+ // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
291
+ "if (-not (Test-Path -LiteralPath $g -PathType Container)) { exit 0 }",
292
+ '& $git --git-dir=$g tag -l "snap-*"'
293
+ ].join("\n");
294
+ }
295
+ function listTagsWithTimeScript(store, gitExe) {
296
+ return [
297
+ "$ErrorActionPreference = 'Stop'",
298
+ "$git = " + psq(gitExe),
299
+ "$g = " + psq(store.git),
300
+ "if (-not (Test-Path -LiteralPath $g -PathType Container)) { exit 0 }",
301
+ '& $git --git-dir=$g for-each-ref --format="%(refname:short) %(creatordate:unix)" "refs/tags/snap-*"'
302
+ ].join("\n");
303
+ }
304
+ function gcScript(store, gitExe) {
305
+ return [
306
+ "$ErrorActionPreference = 'Stop'",
307
+ "$git = " + psq(gitExe),
308
+ "$g = " + psq(store.git),
309
+ "& $git --git-dir=$g gc --quiet --prune=now",
310
+ "Set-Content -LiteralPath (Join-Path $g 'gc.stamp') -Value ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) -Encoding ascii",
311
+ "Write-Output 'GC_OK'"
312
+ ].join("\n");
313
+ }
314
+ function pruneScript(store, gitExe) {
315
+ return [
316
+ "$ErrorActionPreference = 'Stop'",
317
+ "$git = " + psq(gitExe),
318
+ "$g = " + psq(store.git),
319
+ "& $git --git-dir=$g prune",
320
+ "Write-Output 'PRUNE_OK'"
321
+ ].join("\n");
322
+ }
323
+ function killOrphansScript(gitDir) {
324
+ return [
325
+ "# RECALL_CLEANUP",
326
+ "$ErrorActionPreference = 'SilentlyContinue'",
327
+ "$g = " + psq(gitDir),
328
+ // —— 1 级保护:另一活实例心跳(store.dir git-dir 上两级)——
329
+ "$hbf = Join-Path (Split-Path -Parent (Split-Path -Parent $g)) 'heartbeat'",
330
+ "if (Test-Path -LiteralPath $hbf -PathType Leaf) {",
331
+ " $hl = Get-Content -LiteralPath $hbf -TotalCount 1",
332
+ " if ($hl) {",
333
+ " $hp = ('' + $hl).Trim() -split '\\s+'",
334
+ " $a = [int64]0; $b = [int64]0",
335
+ " if (($hp.Count -ge 2) -and [int64]::TryParse($hp[0], [ref]$a) -and [int64]::TryParse($hp[1], [ref]$b)) {",
336
+ " $age = [DateTimeOffset]::Now.ToUnixTimeSeconds() - $b",
337
+ " if (($a -gt 0) -and ($a -ne " + String(process.pid) + ") -and ($age -ge 0) -and ($age -lt " + HEARTBEAT_TTL_S + ")) {",
338
+ " if (Get-Process -Id $a -ErrorAction SilentlyContinue) {",
339
+ " Write-Output ('CLEANUP_OTHER_INSTANCE ' + $a)",
340
+ " exit 0",
341
+ " }",
342
+ " }",
343
+ " }",
344
+ " }",
345
+ "}",
346
+ // —— 2 级保护:新锁(有 git 操作可能正在进行)——
347
+ "$cutoff = (Get-Date).AddMinutes(-" + STALE_LOCK_MIN + ")",
348
+ "$fresh = $false",
349
+ "foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
350
+ " $lp = Join-Path $g $n",
351
+ " if (Test-Path -LiteralPath $lp -PathType Leaf) {",
352
+ " if ((Get-Item -LiteralPath $lp).LastWriteTime -gt $cutoff) { $fresh = $true; break }",
353
+ " }",
354
+ "}",
355
+ "if (-not $fresh) {",
356
+ " $fl = @(Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $cutoff })",
357
+ " if ($fl.Count -gt 0) { $fresh = $true }",
358
+ "}",
359
+ "if ($fresh) {",
360
+ " Write-Output 'CLEANUP_SKIPPED_FRESH_LOCK'",
361
+ " exit 0",
362
+ "}",
363
+ // —— 保护未命中:原有清扫(杀孤儿 + stale 锁)——
364
+ // 标记用变量拼接而非字面量:本脚本进程的命令行(-Command 全文)只有
365
+ // 未展开的 '$g',Where-Object 不会匹配到自己
366
+ "$marker = '--git-dir=' + $g",
367
+ "Get-CimInstance Win32_Process -Filter 'CommandLine IS NOT NULL' | Where-Object { $_.CommandLine.Contains($marker) } | ForEach-Object {",
368
+ " & taskkill /T /F /PID $_.ProcessId | Out-Null",
369
+ "}",
370
+ // 锁清单:index.lock add/checkout 的持久锁,其余是 gc/tag/pack 链路
371
+ // 可能残留的;refs 下的 per-ref 锁用递归兜底(能走到这里说明锁已陈旧)
372
+ "foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
373
+ " Remove-Item -LiteralPath (Join-Path $g $n) -Force",
374
+ "}",
375
+ "Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' | Remove-Item -Force",
376
+ "Write-Output 'CLEANUP_DONE'"
377
+ ].join("\n");
378
+ }
379
+ function purgeTagsScript(store, gitExe, tags) {
380
+ return [
381
+ "$ErrorActionPreference = 'Stop'",
382
+ "$git = " + psq(gitExe),
383
+ "$g = " + psq(store.git),
384
+ "& $git --git-dir=$g tag -d " + tags.map((t) => psq(t)).join(" "),
385
+ "Write-Output 'PURGE_DONE'",
386
+ "exit 0"
387
+ ].join("\n");
388
+ }
389
+ function fileWriteStdinCmd(file) {
390
+ return [
391
+ "$ErrorActionPreference = 'Stop'",
392
+ "$tmp = " + psq(file),
393
+ "$stream = [Console]::OpenStandardInput()",
394
+ "$ms = New-Object System.IO.MemoryStream",
395
+ "$buf = New-Object byte[] 8192",
396
+ "while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) { $ms.Write($buf, 0, $n) }",
397
+ "$text = [Text.UTF8Encoding]::new($false).GetString($ms.ToArray())",
398
+ "[IO.File]::WriteAllText($tmp, $text, [Text.UTF8Encoding]::new($false))"
399
+ ].join("\n");
400
+ }
401
+ function renameFileCmd(src, dst) {
402
+ return "$ErrorActionPreference = 'Stop'\nMove-Item -Force -LiteralPath " + psq(src) + " -Destination " + psq(dst);
403
+ }
404
+ function indexReadCmd(dir) {
405
+ return "Get-Content -LiteralPath " + psq(dir + "\\index.json") + " -Raw -Encoding UTF8 -ErrorAction SilentlyContinue";
406
+ }
407
+ function lineageReadCmd(dir) {
408
+ return "Get-Content -LiteralPath " + psq(dir + "\\lineage.json") + " -Raw -Encoding UTF8 -ErrorAction SilentlyContinue";
409
+ }
410
+ function legacyRmScript(path) {
411
+ return "Remove-Item -Recurse -Force -LiteralPath " + psq(path) + " -ErrorAction SilentlyContinue";
412
+ }
413
+ function excludeReadCmd(file) {
414
+ return "Get-Content -LiteralPath " + psq(file) + " -Raw -Encoding UTF8 -ErrorAction SilentlyContinue";
415
+ }
416
+ function excludeDumpScript(files) {
417
+ const lines = ["$ErrorActionPreference = 'SilentlyContinue'"];
418
+ for (const f of files || []) {
419
+ const q = psq(f);
420
+ lines.push(
421
+ "Write-Output ('EXCLBEGIN ' + " + q + ")",
422
+ "if (Test-Path -LiteralPath " + q + " -PathType Leaf) { Write-Output ([Convert]::ToBase64String([IO.File]::ReadAllBytes(" + q + "))) }",
423
+ "Write-Output 'EXCLEND'"
424
+ );
425
+ }
426
+ return lines.join("\n");
427
+ }
428
+ function dirExistsScript(dir) {
429
+ return "if (Test-Path -LiteralPath " + psq(dir) + " -PathType Container) { Write-Output 'YES' } else { Write-Output 'NO' }";
430
+ }
431
+ function countObjectsScript(store, gitExe) {
432
+ return [
433
+ "$git = " + psq(gitExe),
434
+ "$g = " + psq(store.git),
435
+ "& $git --git-dir=$g count-objects -v"
436
+ ].join("\n");
437
+ }
438
+ function diskUsageScript(dir) {
439
+ const q = psq(dir);
440
+ return [
441
+ "$usageStack = [System.Collections.Generic.Stack[string]]::new()",
442
+ "$usageStack.Push(" + q + ")",
443
+ "$sum = [long]0",
444
+ "while ($usageStack.Count -gt 0) {",
445
+ " $dir = $usageStack.Pop()",
446
+ " try {",
447
+ " $di = [System.IO.DirectoryInfo]::new($dir)",
448
+ " foreach ($f in $di.EnumerateFiles()) { $sum += $f.Length }",
449
+ " foreach ($d in $di.EnumerateDirectories()) { $usageStack.Push($d.FullName) }",
450
+ " } catch {}",
451
+ "}",
452
+ "Write-Output $sum"
453
+ ].join("\n");
454
+ }
455
+ function listSubdirsScript(dir) {
456
+ return "Get-ChildItem -LiteralPath " + psq(dir) + " -Directory -ErrorAction SilentlyContinue | ForEach-Object { Write-Output $_.FullName }";
457
+ }
458
+ function storesDumpScript(container, extraDirs) {
459
+ const lines = [
460
+ "$ErrorActionPreference = 'Stop'",
461
+ "$dirs = @()"
462
+ ];
463
+ if (container) {
464
+ lines.push("$base = " + psq(container));
465
+ lines.push("$dirs += @(Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })");
466
+ }
467
+ for (const d of extraDirs || []) lines.push("$dirs += " + psq(d));
468
+ lines.push(
469
+ "foreach ($d in $dirs) {",
470
+ " if (-not $d) { continue }",
471
+ " if (-not (Test-Path -LiteralPath $d -PathType Container)) { continue }",
472
+ ' Write-Output ("==DIR " + $d)',
473
+ " $rt = Join-Path $d 'root.txt'",
474
+ " if (Test-Path -LiteralPath $rt -PathType Leaf) {",
475
+ " $rv = (Get-Content -LiteralPath $rt -Raw -Encoding UTF8 -ErrorAction SilentlyContinue)",
476
+ // 先规整成单行再拼接:root 是单行路径,但用户手改文件可能带 CRLF,
477
+ // 直接拼会把标记结构打乱
478
+ " if ($rv) { $rv = $rv.Trim() }",
479
+ " Write-Output ('ROOT ' + $rv)",
480
+ " } else {",
481
+ " Write-Output 'ROOT '",
482
+ " }",
483
+ " Write-Output 'INDEXBEGIN'",
484
+ " $ix = Join-Path $d 'index.json'",
485
+ " if (Test-Path -LiteralPath $ix -PathType Leaf) {",
486
+ " $j = Get-Content -LiteralPath $ix -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
487
+ " if ($j) { Write-Output $j.TrimEnd() }",
488
+ " }",
489
+ " Write-Output 'INDEXEND'",
490
+ " Write-Output 'LINEAGEBEGIN'",
491
+ " $lg = Join-Path $d 'lineage.json'",
492
+ " if (Test-Path -LiteralPath $lg -PathType Leaf) {",
493
+ " $l = Get-Content -LiteralPath $lg -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
494
+ " if ($l) { Write-Output $l.TrimEnd() }",
495
+ " }",
496
+ " Write-Output 'LINEAGEEND'",
497
+ "}",
498
+ "exit 0"
499
+ );
500
+ return lines.join("\n");
501
+ }
502
+ export {
503
+ FIDELITY_ATTRS,
504
+ HEARTBEAT_TTL_S,
505
+ MAX_FILE_BYTES,
506
+ STALE_LOCK_MIN,
507
+ UTF8_PRELUDE,
508
+ countObjectsScript,
509
+ diffScript,
510
+ dirExistsScript,
511
+ diskUsageScript,
512
+ ensureGitScript,
513
+ excludeDumpScript,
514
+ excludeReadCmd,
515
+ fileWriteStdinCmd,
516
+ gcScript,
517
+ homeDirScript,
518
+ indexReadCmd,
519
+ killOrphansScript,
520
+ legacyRmScript,
521
+ lineageReadCmd,
522
+ listSubdirsScript,
523
+ listTagsScript,
524
+ listTagsWithTimeScript,
525
+ migrateScript,
526
+ mkdirScript,
527
+ pruneScript,
528
+ psq,
529
+ purgeTagsScript,
530
+ renameFileCmd,
531
+ rescueScript,
532
+ resolveGitScript,
533
+ rollbackScript,
534
+ snapshotScript,
535
+ storesDumpScript,
536
+ stripBom
537
+ };