dsh-recall-plugin 2.4.9 → 2.4.10

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [2.4.10] - 2026-10-07
6
+
7
+ ### 修复
8
+
9
+ - **回退恢复侧从「整树解压」改为只处理 diff 变更集(issue #22)**:旧版 `rollbackScript` 的 `$restored` 是目标树全量条数——改 1 个文件也把整棵快照树打 zip/解包一遍(大工作区 GB 级三遍 IO;issue #20 实机 39.2 万条幽灵条目时打出 5.11 GB zip),未变更文件也被 `-Force` 覆写、mtime 全变误触发构建增量/文件监听。现按 sha 求出恢复集(目标有、当前无或内容不同,与 `diffScript` 的 modified+restored 同集合),`git archive <tag> -- <paths>` 分批(100/批)只解压变更项,变更过半时回退整树单遍(N/100 次 archive+解压的启动开销超过单遍);`ROLLBACK_OK` 的 restored 计数随之变成实际恢复条数,与 preview 的 diff 计数逐项相等。pwsh/posix 双侧对偶(POSIX 走 `archive | tar -x -m` 分批 pathspec);archive 失败 pwsh 侧显式 throw、POSIX 侧 pipefail 非零退出——半回退不许报 `ROLLBACK_OK`(F-G2 纪律不变)。实测:6 文件工作区改 1 删 1 增 1 的回退 `ROLLBACK_OK 1 2`(旧版报 7),未变更文件 mtime 不动;变更过半整树路径双侧实测正确。
10
+ - **junction 防护三处评审补强(PR #21 P3 nit)**:① 机器生成的 exclude 行补 gitignore 转义——目录名以 `#`/`!` 开头或含 `[` 的 junction 此前会被解析成注释/反选/字符组而静默漏排除(自引用 `#hash` 金丝雀夹具实测漏排 → 修复后 1 条索引);② 发现行排序,exclude 内容序与 NTFS 枚举序脱钩,消无谓重写;③ 良性 junction 开反选逃生口——exclude 写盘顺序改为 base + 机器行 + 用户行(gitignore 后写胜出),排除配置追加 `!路径/` 即可把指向工作区外的 junction 重新纳入快照(实测默认 1 条 → 反选后 3 条索引),切勿对自引用 junction 使用,README 双语已更新。
11
+
12
+ - **win32 目录重解析点把影子仓库索引撑爆、撤回退化为分钟级(issue #20,PR #21,外部贡献 @PrismScopes)**:git for Windows 把 NTFS junction 当普通目录,`add -A` 递归进入链接目标(`core.symlinks` 取 true / false / 默认实测同样递归,无法配置规避),自引用 junction(`a/link -> a`)按 Windows「路径中最多 31 个重解析点」上限把同一棵子树重复索引——实机病例 1.24 万条 → 39.2 万条、`.git/index` 168 MB,preview/快照/回退退化到分钟级(用户表现为「永久卡在正在计算变更」)。修法=`excludeSyncBlock` 在 `add -A` 之前遍历目录树(不进入链接目标)把目录重解析点按相对 root 路径 + 尾斜杠并入 `info/exclude`,git 根本不进入它们;已跟踪的旧幽灵条目由既有 `ls-files -i -c` 分支一次清掉;`oversizeBlock` 同一次压栈过滤顺带剪掉重解析点。遍历只枚举目录、复用既有目录名剪枝,无重解析点的工作区不产生 exclude 差异,条件化比对跳过重写与清理循环(常态零开销)。POSIX 侧有意不做:git 把指向目录的符号链接记成 `120000` 条目、不递归进目标,照搬反而会把合法快照内容剔出快照。已知限制(README 双语已声明):工作区内的目录重解析点一律不参与快照(含指向工作区外的良性 junction,无 `!` 反选逃生口);POSIX bind mount 环不覆盖。维护者评审实测数据:2 真实文件 + 自引用 junction 夹具,索引 96 条(93 幽灵)→ 3 条 / 0 幽灵,`.git/index` 38 KB → 331 B;门禁 typecheck / 单测 498 / `verify:host` 全绿。
13
+ - **大幽灵存量的首诊清理从「分钟级卡死」改整棵重建(PR #21 评审补强)**:旧清理路径对 37.9 万条幽灵条目走 `ls-files -i -c` + 每批 100 条 `update-index --force-remove`,约 3797 次 git 子进程、每次重写巨型 index(168 MB 渐缩),估 10–60 分钟——升级后第一次快照仍会表现为「卡在正在计算变更」。现按 10000 条阈值切换:`read-tree --empty` 清索引、交给随后的 `add -A` 按新 exclude 整棵重建(`gcScript` 治 issue #18 同款技巧,index 是可重建缓存),实测 1.24 万条幽灵夹具首诊 **2 秒**完成;`read-tree` 失败显式 throw(静默失败会让幽灵条目随 exclude 已重写永久滞留、清理不再触发)。重解析点遍历另加 `if ($root)` 显式守卫:`ensureGitScript` 无 `$root`,原路径靠 `Push($null)` 抛错被 `catch` 吞掉的巧合跳过,现成为显式设计(三处主脚本随后在 `add -A` 之前重做发现,无漏检窗口)。
14
+
5
15
  ## [2.4.9] - 2026-10-03
6
16
 
7
17
  ### 变更
package/README.en.md CHANGED
@@ -63,6 +63,7 @@ Boundaries accepted by design and edge cases not yet covered — worth checking
63
63
  - Recall cannot be initiated while an agent is running in the target workspace (by design — stop the agent first).
64
64
  - Supports Windows (PowerShell 5.1/7 + git CLI) and Linux/macOS (bash + git CLI). Windows is thoroughly verified on real machines; Linux has been fully tested on WSL2 (Ubuntu 26.04, bash 5.3 + git 2.53), including Chinese paths, home fallback, session cleanup, and gc; the macOS side is written to be bash 3.2 compatible but has not been tested on real hardware yet.
65
65
  - Nested git repositories inside the workspace (subdirectories with their own `.git`) cannot be indexed: the snapshot proceeds for everything else (fail-open, with a notice listing the skipped paths), but their contents do not participate in recalls.
66
+ - **Directory reparse points** in the workspace (junctions / directory symlinks) are not snapshotted by default: the subtree they point at is not indexed, so its contents do not participate in recalls. This is required on Windows, where git for Windows treats a junction as an ordinary directory and a self-referencing one inflates the index up to Windows' 31-reparse-point path limit (a two-file workspace measured 64 index entries); on POSIX a symlink still enters the snapshot (git records it as a `120000` entry and never recurses). To deliberately snapshot a **benign** junction (pointing outside the workspace, non-self-referencing), append `!path/` (e.g. `!ext-link/`) in the exclusion settings — user rules take effect after machine-generated ones; **never do this for a self-referencing junction** (the explosion vector).
66
67
  - Extreme cases like filenames containing newlines/TAB are beyond the diff list's parsing capability (negligible probability).
67
68
  - **Interplay with dsh-routing-suite (progressive tool-disclosure router)**: if you also run its router-standard preset, a recall forks a new session via `sessions.fork`, which resets the router's stage to its default (the tool surface temporarily narrows). Symptoms, root cause and the fix are documented in [docs/routing-interplay.md](docs/routing-interplay.md) (Chinese).
68
69
 
package/README.md CHANGED
@@ -60,6 +60,7 @@
60
60
  - 目标工作区的 agent 正在运行时无法发起撤回(防护设计,先停止 agent 再撤回)。
61
61
  - 支持 Windows(PowerShell 5.1/7 + git CLI)与 Linux/macOS(bash + git CLI)。Windows 真机验证充分;Linux 已在 WSL2(Ubuntu 26.04,bash 5.3 + git 2.53)实测全流程(含中文路径、home 降级、会话清理、gc);macOS 侧脚本按 bash 3.2 兼容编写,尚未真机实测。
62
62
  - 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)无法索引:其余部分快照照常(fail-open,页面会提示跳过了哪些路径),但其内容不参与回退。
63
+ - 工作区内的**目录重解析点**(junction / 目录符号链接)默认不参与快照:它们指向的子树不会被索引,因此那部分内容不参与回退。win32 上这是必需的——git for Windows 把 junction 当普通目录递归,自引用 junction 会让索引膨胀到 Windows 的 31 层重解析点上限(实测 2 个文件的工作区产出 64 条索引);POSIX 侧符号链接照常进快照(git 记成 `120000` 条目,不递归进目标)。确需把某个**良性** junction(指向工作区外、非自引用)纳入快照时,可在排除配置里追加 `!路径/`(如 `!ext-link/`)反选恢复——用户排除行在机器生成行之后生效;**切勿对自引用 junction 使用**(爆炸根因)。
63
64
  - 文件名含换行/TAB 的极端情形超出 diff 清单的解析能力(概率可忽略)。
64
65
  - **与 dsh-routing-suite(渐进式工具披露路由)的交互**:同时启用其 router-standard 预设时,撤回经 `sessions.fork` 出新会话会把路由阶段重置为默认(工具面临时收窄)。现象、成因与解决方案见 [docs/routing-interplay.md](docs/routing-interplay.md)。
65
66
 
@@ -224,19 +224,44 @@ function rollbackScript(root, store, gitExe, tag, base) {
224
224
  return [
225
225
  "set -e -o pipefail",
226
226
  collectListsBlock(store, gitExe, root, tag, base),
227
- `trap 'rm -f "$tmpc" "$tmpt"' EXIT`,
228
- `restored=$(wc -l < "$tmpt" | tr -d ' ')`,
227
+ "tmpr=" + psq(store.dir + "/diff-restore.$$"),
228
+ `trap 'rm -f "$tmpc" "$tmpt" "$tmpr"' EXIT`,
229
+ // 恢复集 = 目标有、当前无或 sha 不同(awk 结构与 diffScript 同款:
230
+ // cur 侧 "mode sha stage<TAB>path" 取 a[2],target 侧 "mode type sha<TAB>path" 取 a[3])
231
+ "awk -F'\\t' '",
232
+ " FNR==1 { fidx++ }",
233
+ ' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
234
+ ' { split($1, a, " "); tgt[$2]=a[3] }',
235
+ " END { for (p in tgt) if (!(p in cur) || cur[p] != tgt[p]) print p }",
236
+ `' "$tmpc" "$tmpt" > "$tmpr"`,
237
+ `restored=$(wc -l < "$tmpr" | tr -d ' ')`,
238
+ `total=$(wc -l < "$tmpt" | tr -d ' ')`,
229
239
  'if [ "$restored" -gt 0 ]; then',
230
- // -m(--touch):解包不恢复归档成员的 mtime(文件 mtime = 解包时刻)。
231
- // 必须如此:tar 默认保留归档内 mtime,而快照→篡改→回滚常在数秒内
232
- // 完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录碰撞,下一次
233
- // add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区内容与快照
234
- // 从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
240
+ ' if [ $((restored * 2)) -ge "$total" ]; then',
241
+ // -m(--touch,两处 tar 调用同款):解包不恢复归档成员的 mtime(文件
242
+ // mtime = 解包时刻)。必须如此:tar 默认保留归档内 mtime,而快照→篡改→
243
+ // 回滚常在数秒内完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录
244
+ // 碰撞,下一次 add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区
245
+ // 内容与快照从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
235
246
  // Expand-Archive 天然把 mtime 设为解包时刻,无此问题;-m 让 tar 对齐。
236
- ' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
247
+ ' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
248
+ " else",
249
+ " batch=()",
250
+ " while IFS= read -r p; do",
251
+ ' if [ -z "$p" ]; then continue; fi',
252
+ ' batch+=("$p")',
253
+ ' if [ "${#batch[@]}" -ge 100 ]; then',
254
+ ' "$git" --literal-pathspecs --git-dir="$g" archive ' + psq(tag) + ' -- "${batch[@]}" | tar -x -m -C "$root"',
255
+ " batch=()",
256
+ " fi",
257
+ ' done < "$tmpr"',
258
+ ' if [ "${#batch[@]}" -gt 0 ]; then',
259
+ ' "$git" --literal-pathspecs --git-dir="$g" archive ' + psq(tag) + ' -- "${batch[@]}" | tar -x -m -C "$root"',
260
+ " fi",
261
+ " fi",
237
262
  "fi",
238
263
  "tmpd=" + psq(store.dir + "/diff-del.$$"),
239
- `trap 'rm -f "$tmpc" "$tmpt" "$tmpd"' EXIT`,
264
+ `trap 'rm -f "$tmpc" "$tmpt" "$tmpr" "$tmpd"' EXIT`,
240
265
  "awk -F'\\t' '",
241
266
  " FNR==1 { fidx++ }",
242
267
  " fidx==1 { cur[$2]=1; next }",
@@ -17,15 +17,48 @@ function dropGitlinksBlock() {
17
17
  "}"
18
18
  ].join("\n");
19
19
  }
20
- function oversizeBlock(maxBytes) {
20
+ function dirSkipSetBlock(name) {
21
21
  return [
22
- "$oversizeSkip = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)",
22
+ "$" + name + " = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)",
23
23
  "foreach ($p in $lines) {",
24
24
  ' $t = "$p".Trim()',
25
25
  " if (-not $t -or $t.StartsWith('#') -or $t.StartsWith('!')) { continue }",
26
26
  " $t = $t -replace '/$', ''",
27
- " if ($t -notmatch '[*?\\[\\]/\\\\]') { [void]$oversizeSkip.Add($t) }",
27
+ " if ($t -notmatch '[*?\\[\\]/\\\\]') { [void]$" + name + ".Add($t) }",
28
+ "}"
29
+ ].join("\n");
30
+ }
31
+ function reparseDirsBlock() {
32
+ return [
33
+ dirSkipSetBlock("reparseSkip"),
34
+ "$reparseRel = [System.Collections.Generic.List[string]]::new()",
35
+ "if ($root) {",
36
+ "$reparseStack = [System.Collections.Generic.Stack[string]]::new()",
37
+ "$reparseStack.Push($root)",
38
+ "while ($reparseStack.Count -gt 0) {",
39
+ " $dir = $reparseStack.Pop()",
40
+ " try {",
41
+ " $di = [System.IO.DirectoryInfo]::new($dir)",
42
+ " foreach ($d in $di.EnumerateDirectories()) {",
43
+ " if ($reparseSkip.Contains($d.Name)) { continue }",
44
+ " if (($d.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {",
45
+ " $rel = $d.FullName.Substring($root.Length + 1).Replace('\\','/')",
46
+ ' $rel = $rel -replace "\\[", "\\["',
47
+ ' if ($rel.StartsWith("#") -or $rel.StartsWith("!")) { $rel = "\\" + $rel }',
48
+ " $reparseRel.Add($rel + '/')",
49
+ " continue",
50
+ " }",
51
+ " $reparseStack.Push($d.FullName)",
52
+ " }",
53
+ " } catch {}",
54
+ "}",
28
55
  "}",
56
+ "$reparseRel.Sort()"
57
+ ].join("\n");
58
+ }
59
+ function oversizeBlock(maxBytes) {
60
+ return [
61
+ dirSkipSetBlock("oversizeSkip"),
29
62
  "$oversizeStack = [System.Collections.Generic.Stack[string]]::new()",
30
63
  "$oversizeStack.Push($root)",
31
64
  "$oversizeRel = [System.Collections.Generic.List[string]]::new()",
@@ -39,7 +72,9 @@ function oversizeBlock(maxBytes) {
39
72
  " }",
40
73
  " }",
41
74
  " foreach ($d in $di.EnumerateDirectories()) {",
42
- " if (-not $oversizeSkip.Contains($d.Name)) { $oversizeStack.Push($d.FullName) }",
75
+ " if ($oversizeSkip.Contains($d.Name)) { continue }",
76
+ " if (($d.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }",
77
+ " $oversizeStack.Push($d.FullName)",
43
78
  " }",
44
79
  " } catch {}",
45
80
  "}",
@@ -56,6 +91,12 @@ function excludeSyncBlock(excludeFile, base) {
56
91
  "$userPats = @()",
57
92
  "if (Test-Path -LiteralPath $exFile) { $userPats = @(Get-Content -LiteralPath $exFile -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $t = $_.Trim(); $t -and -not $t.StartsWith('#') }) }",
58
93
  "$lines = @('') + @(" + baseList + ") + $userPats",
94
+ reparseDirsBlock(),
95
+ // 写盘顺序 base + reparse + userPats(评审 P3 逃生口):gitignore 后写胜出,
96
+ // 用户行垫底才可用 !path/ 反选重纳良性 junction(指向工作区外、非自引用);
97
+ // 自引用 junction 切勿反选(爆炸根因,见 reparseDirsBlock)。上方 $lines
98
+ //(base+userPats)先供 reparseDirsBlock 构目录名剪枝集,判据语义不变。
99
+ "$lines = @('') + @(" + baseList + ") + @($reparseRel) + $userPats",
59
100
  "$exc = Join-Path $g 'info\\exclude'",
60
101
  "$excOld = @(Get-Content -LiteralPath $exc -Encoding UTF8 -ErrorAction SilentlyContinue)",
61
102
  "$same = ($excOld.Count -eq $lines.Count)",
@@ -67,9 +108,14 @@ function excludeSyncBlock(excludeFile, base) {
67
108
  "if (-not $same) {",
68
109
  " Set-Content -LiteralPath $exc -Value $lines -Encoding utf8",
69
110
  " $hit = @(& $git -c core.quotePath=false --literal-pathspecs --git-dir=$g ls-files -i -c --exclude-from=$exc | Where-Object { $_ })",
70
- " for ($i = 0; $i -lt $hit.Count; $i += 100) {",
71
- " $batch = @($hit[$i..([Math]::Min($i + 99, $hit.Count - 1))])",
72
- " & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch",
111
+ " if ($hit.Count -gt 10000) {",
112
+ " & $git --git-dir=$g read-tree --empty",
113
+ " if ($LASTEXITCODE -ne 0) { throw ('git read-tree --empty failed (exit ' + $LASTEXITCODE + ')') }",
114
+ " } else {",
115
+ " for ($i = 0; $i -lt $hit.Count; $i += 100) {",
116
+ " $batch = @($hit[$i..([Math]::Min($i + 99, $hit.Count - 1))])",
117
+ " & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $batch",
118
+ " }",
73
119
  " }",
74
120
  "}"
75
121
  ].join("\n");
@@ -250,18 +296,42 @@ function rollbackScript(root, store, gitExe, tag, base) {
250
296
  // 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
251
297
  "$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage",
252
298
  "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
299
+ "$curMap = @{}",
300
+ "foreach ($r in @($curOut)) {",
301
+ " if (-not $r) { continue }",
302
+ ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
303
+ ' $sha = ($r.Substring(0, $tab) -split " ")[1]',
304
+ " $curMap[$path] = $sha",
305
+ "}",
253
306
  "$targetMap = @{}",
254
307
  "foreach ($r in @($targetOut)) {",
255
308
  " if (-not $r) { continue }",
256
309
  ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
257
- " $targetMap[$path] = $true",
310
+ ' $sha = ($r.Substring(0, $tab) -split " ")[2]',
311
+ " $targetMap[$path] = $sha",
312
+ "}",
313
+ // 恢复集 = 目标有、当前无或 sha 不同(与 diffScript 的 modified+restored 同集合)
314
+ "$restoreRel = [System.Collections.Generic.List[string]]::new()",
315
+ "foreach ($k in $targetMap.Keys) {",
316
+ " if (-not $curMap.ContainsKey($k) -or $curMap[$k] -ne $targetMap[$k]) { $restoreRel.Add($k) }",
258
317
  "}",
259
- "$restored = $targetMap.Count",
318
+ "$restored = $restoreRel.Count",
260
319
  "if ($restored -gt 0) {",
261
320
  " $zip = " + psq(store.dir + "\\restore-tmp.zip"),
262
- " & $git --git-dir=$g archive --format=zip --output=$zip " + psq(tag),
263
- " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
264
- " Remove-Item -LiteralPath $zip -Force",
321
+ " if ($restoreRel.Count * 2 -ge $targetMap.Count) {",
322
+ " & $git --git-dir=$g archive --format=zip --output=$zip " + psq(tag),
323
+ " if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
324
+ " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
325
+ " Remove-Item -LiteralPath $zip -Force",
326
+ " } else {",
327
+ " for ($i = 0; $i -lt $restoreRel.Count; $i += 100) {",
328
+ " $batch = $restoreRel.GetRange($i, [Math]::Min(100, $restoreRel.Count - $i))",
329
+ " & $git --literal-pathspecs --git-dir=$g archive --format=zip --output=$zip " + psq(tag) + " -- $batch",
330
+ " if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
331
+ " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
332
+ " Remove-Item -LiteralPath $zip -Force",
333
+ " }",
334
+ " }",
265
335
  "}",
266
336
  // 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下 Remove-Item
267
337
  // 失败直接抛终止错误、pwsh 以非零码退出;POSIX 侧 rm 在 set -e 的 if
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.4.9",
3
+ "version": "2.4.10",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",