dsh-recall-plugin 2.1.0 → 2.2.0

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.
@@ -43,6 +43,14 @@ export const STALE_LOCK_MIN = 5
43
43
  // 仍在窗口内。
44
44
  export const HEARTBEAT_TTL_S = 900
45
45
 
46
+ // 影子仓库 info/attributes 固化内容(issue #12 字节保真):与 pwsh 版同名
47
+ // 常量逐字同值(scripts-contract 钉),完整语义与逐项动机见 scripts.pwsh.js
48
+ // 同名常量注释。一句话:git archive/add 都会应用快照树里项目自己的
49
+ // .gitattributes(text=auto + 缺省 core.eol=native 的转换,仓库级
50
+ // autocrlf=false 挡不住),info/attributes 优先级最高、对全部路径一票否决,
51
+ // 快照 capture/restore 两侧逐字节保真。
52
+ export const FIDELITY_ATTRS = '* -text -filter -ident -export-ignore -export-subst -working-tree-encoding'
53
+
46
54
  // bash/cat 输出无 BOM;保留同名导出维持两套模板接口一致(幂等无害)
47
55
  export function stripBom(text) {
48
56
  return text.replace(/^\uFEFF/, '')
@@ -67,12 +75,15 @@ function dropGitlinksBlock() {
67
75
  // 漏看个别文件是 fail-open,可接受——与 pwsh 版同策略。
68
76
  // 阈值按调用注入(store.maxFileBytes,config 可调),不读模块常量。
69
77
  // 依赖外层已定义的 $git/$g/$root。
78
+ // PF-9 合批:find 命中经管道剥前缀后 xargs -0 多路径合参——xargs 自适应
79
+ // 批次(规避 ARG_MAX)等价 win32 侧显式 100 条/批;-0 保证路径不分裂;
80
+ // xargs 失败/空输入 || true 兜住(fail-open 语义与逐条版一致,残留条目
81
+ // 不进 index 的代价由下次快照幂等重试)。
70
82
  function oversizeBlock(maxBytes) {
71
83
  return [
72
84
  'find "$root" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + 'c -print0 2>/dev/null | while IFS= read -r -d \'\' f; do',
73
- ' rel=${f#"$root"/}',
74
- ' "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- "$rel"',
75
- 'done'
85
+ ' printf \'%s\\0\' "${f#"$root"/}"',
86
+ "done | xargs -0 \"$git\" --literal-pathspecs --git-dir=\"$g\" update-index --force-remove -- 2>/dev/null || true",
76
87
  ].join('\n')
77
88
  }
78
89
 
@@ -83,6 +94,14 @@ function oversizeBlock(maxBytes) {
83
94
  // 兼容 Windows 上编辑带 CRLF 的 exclude.txt。
84
95
  // base 基础排除表按调用注入(config.baseExcludes 可调),不硬编码。
85
96
  // 依赖外层已定义的 $git/$g。
97
+ // - PF-9 条件化:新旧内容比对(命令替换对两侧同样剥尾随换行,比对稳定)
98
+ // 相同则跳过重写**并跳过**清理循环(每条消息常态省 1 次 git 子进程 +
99
+ // 1 次盘写);语义安全论证见 pwsh 版同注释(exclude 未变时 index 已净,
100
+ // add -A 因排除先生效不会加回;「改排除即时生效」承诺不变)。
101
+ // - PF-9 合批:ls-files -z 命中经 xargs -0 多路径合参——xargs 自适应批次
102
+ // 本就是为规避 ARG_MAX 设计(等价 win32 侧显式 100 条/批的分块纪律),
103
+ // -0 保证空格/中文路径不分裂;空输入时 GNU xargs 空跑一次 update-index
104
+ // (usage 退出,2>/dev/null + || true 兜住,BSD xargs 空输入不执行)。
86
105
  function excludeSyncBlock(excludeFile, base) {
87
106
  // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
88
107
  // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
@@ -101,10 +120,12 @@ function excludeSyncBlock(excludeFile, base) {
101
120
  ' user_pats="$user_pats$t\\n"',
102
121
  ' done < "$ex_file"',
103
122
  'fi',
104
- "printf '\\n" + baseLines.replace(/\\/g, '\\\\').replace(/%/g, '%%') + "%b' \"$user_pats\" > \"$exc\"",
105
- '"$git" -c core.quotePath=false --literal-pathspecs --git-dir="$g" ls-files -i -c --exclude-from="$exc" -z 2>/dev/null | while IFS= read -r -d \'\' p; do',
106
- ' if [ -n "$p" ]; then "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- "$p"; fi',
107
- 'done || true'
123
+ "new_exc=$(printf '\\n" + baseLines.replace(/\\/g, '\\\\').replace(/%/g, '%%') + "%b' \"$user_pats\")",
124
+ 'old_exc=$(cat "$exc" 2>/dev/null || true)',
125
+ 'if [ "$new_exc" != "$old_exc" ]; then',
126
+ " printf '%s\\n' \"$new_exc\" > \"$exc\"",
127
+ ' "$git" -c core.quotePath=false --literal-pathspecs --git-dir="$g" ls-files -i -c --exclude-from="$exc" -z 2>/dev/null | xargs -0 "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- 2>/dev/null || true',
128
+ 'fi',
108
129
  ].join('\n')
109
130
  }
110
131
 
@@ -201,12 +222,33 @@ export function ensureGitScript(store, gitExe, base) {
201
222
  '"$git" --git-dir="$g" config core.longpaths true',
202
223
  '"$git" --git-dir="$g" config core.autocrlf false',
203
224
  '"$git" --git-dir="$g" config advice.addEmbeddedRepo false',
225
+ // 属性固化(issue #12,内容见 FIDELITY_ATTRS):info/ 由 git init 自带
226
+ // (info/exclude 模板,excludeSyncBlock 同样依赖),无需建目录;每次
227
+ // ensureGit 重写幂等,存量仓库升级后首次 init 自然补上。
228
+ 'printf ' + psq(FIDELITY_ATTRS + '\n') + ' > "$g/info/attributes"',
204
229
  excludeSyncBlock(store.excludeFile, base),
205
230
  'stamp="$g/gc.stamp"',
206
231
  'if [ -f "$stamp" ]; then printf \'GIT_OK %s\\n\' "$(head -n1 "$stamp" 2>/dev/null)"; else echo GIT_OK; fi'
207
232
  ].join('\n')
208
233
  }
209
234
 
235
+ // 存量归一化迁移(issue #12,语义与动机详见 pwsh 版 attrsMigrateBlock 注释):
236
+ // 属性固化后旧索引条目仍指向归一化 blob(stat 缓存时序依赖地跳过重哈希),
237
+ // --renormalize 按当前属性重哈希一次;无 pathspec 是空操作,必须带 ':(top)'
238
+ // 顶层魔法 pathspec。标记文件每仓库至多跑一次;失败(老 git 无该选项等)
239
+ // 只跳过标记下条消息重试,不连累快照主流程(set -e 下 || rc=$? 捕获)。
240
+ // 依赖外层已定义的 $git/$g/$root;仅 snapshotScript 使用。
241
+ function attrsMigrateBlock() {
242
+ return [
243
+ 'mig_stamp="$g/attrs-v1.stamp"',
244
+ 'if [ ! -f "$mig_stamp" ]; then',
245
+ ' migrc=0',
246
+ ' "$git" --git-dir="$g" --work-tree="$root" add --renormalize --ignore-errors -- \':(top)\' >/dev/null 2>&1 || migrc=$?',
247
+ ' if [ "$migrc" -le 1 ]; then printf \'1\\n\' > "$mig_stamp" 2>/dev/null || true; fi',
248
+ 'fi',
249
+ ].join('\n')
250
+ }
251
+
210
252
  // 快照:add -A → write-tree → commit-tree(孤儿提交)→ tag(语义同 pwsh 版)。
211
253
  // tag -f:事件重放/重发会产生重复 messageId,裸 tag 对已存在 tag fatal
212
254
  // 导致整条快照失败;-f 把 tag 指到最新提交,语义为「同一条消息取最新状态」。
@@ -219,6 +261,7 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
219
261
  heartbeatBlock(),
220
262
  dropGitlinksBlock(),
221
263
  excludeSyncBlock(store.excludeFile, base),
264
+ attrsMigrateBlock(),
222
265
  // fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
223
266
  // 下「无法索引的路径」以退出码 1 结束但索引已落盘;≥2 才是真 fatal,
224
267
  // 显式退出让 runShell 抛错(set -e 对 add 非零本会终止,但 || rc=$?
@@ -237,6 +280,9 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
237
280
  'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
238
281
  'commit=$("$git" --git-dir="$g" -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree "$tree" -m ' + psq('snapshot ' + messageId) + ')',
239
282
  '"$git" --git-dir="$g" tag -f ' + psq('snap-' + messageId) + ' "$commit" >/dev/null',
283
+ // PF-1:TREE 行随 SNAP_OK 回传 add -A 之后的 index 树指纹(语义见 pwsh 版
284
+ // 同名注释)——execute 与 preview 指纹比对判 STALE,免整条重复 diff
285
+ 'echo "TREE $tree"',
240
286
  'echo SNAP_OK'
241
287
  ].join('\n')
242
288
  }
@@ -274,12 +320,15 @@ function collectListsBlock(store, gitExe, root, tag, base) {
274
320
  // target 侧 "mode type sha<TAB>path" 取 a[3]),输出 TSV「kind<TAB>path」
275
321
  // 逐行打印,Node 侧解析(不在 bash 里拼 JSON——没有 jq 依赖、
276
322
  // 转义路径的坑也一并消失)。sort -k2 按 path 确定序,与 pwsh 版对齐。
323
+ // PF-1:末尾追加 write-tree + TREE 行(add 后的 index 树指纹,语义见 pwsh
324
+ // 版同注释)。POSIX 侧 TSV 文本轻、无 ConvertTo-Json 序列化开销,不做
325
+ // TOTAL/截断——全量输出,截断仍由 JS 侧 slice(与既有语义一致)。
277
326
  export function diffScript(root, store, gitExe, tag, base) {
278
327
  return [
279
328
  'set -e -o pipefail',
280
329
  collectListsBlock(store, gitExe, root, tag, base),
281
330
  "trap 'rm -f \"$tmpc\" \"$tmpt\"' EXIT",
282
- 'awk -F\'\\t\' -v OFS=\'\\t\' \'',
331
+ 'awk -F\'\\t\' -v OFS=\'\\t\' \'.',
283
332
  ' FNR==1 { fidx++ }',
284
333
  ' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
285
334
  ' { split($1, a, " "); tgt[$2]=a[3] }',
@@ -291,6 +340,8 @@ export function diffScript(root, store, gitExe, tag, base) {
291
340
  ' for (p in tgt) if (!(p in cur)) print "restored", p',
292
341
  ' }',
293
342
  "' \"$tmpc\" \"$tmpt\" | sort -t$'\\t' -k2,2",
343
+ 'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
344
+ 'echo "TREE $tree"',
294
345
  'exit 0'
295
346
  ].join('\n')
296
347
  }
@@ -473,6 +524,13 @@ export function renameFileCmd(src, dst) {
473
524
  return 'mv -f -- ' + psq(src) + ' ' + psq(dst)
474
525
  }
475
526
 
527
+ // 任意长度文本写入(PF-2,语义见 pwsh 版同名注释):stdin 传全文 + 单进程
528
+ // 落盘。POSIX 原本就直写 stdin(cat > tmp 此前内联在 store.js,PF-2 起迁进
529
+ // 模板统一走同名导出),bash 无编码/长度问题,模板本体就是这条 cat。
530
+ export function fileWriteStdinCmd(file) {
531
+ return 'cat > ' + psq(file)
532
+ }
533
+
476
534
  // 索引读取(写入走 stdin:见 snapshots.js saveIndex 的 POSIX 分支,
477
535
  // 不经命令行传参,天然没有 32767/128KB argv 上限问题)
478
536
  export function indexReadCmd(dir) {
@@ -497,6 +555,22 @@ export function excludeReadCmd(file) {
497
555
  return 'cat ' + psq(file) + ' 2>/dev/null || true'
498
556
  }
499
557
 
558
+ // 批量读全部 exclude 文件(PF-8,语义见 pwsh 版同注释):内容 base64 单行
559
+ // 输出(任意文本免疫定界混淆)。GNU base64 默认 76 字符折行、BSD(macOS)
560
+ // 不折行且无 -w——统一 base64 | tr -d '\n' 兼容两侧;读失败输出空段。
561
+ export function excludeDumpScript(files) {
562
+ const lines = []
563
+ for (const f of files || []) {
564
+ const q = psq(f)
565
+ lines.push(
566
+ "printf 'EXCLBEGIN %s\\n' " + q,
567
+ 'if [ -f ' + q + ' ]; then base64 ' + q + " 2>/dev/null | tr -d '\\n'; fi",
568
+ "echo 'EXCLEND'"
569
+ )
570
+ }
571
+ return lines.join('\n')
572
+ }
573
+
500
574
  // 目录存在探测:YES/NO 定长标记与 pwsh 版逐字同语义(容器路径本身在
501
575
  // JS 侧解析,POSIX 不需要 homeContainerScript 的 shell 版)。
502
576
  export function dirExistsScript(dir) {
@@ -526,7 +600,8 @@ export function listSubdirsScript(dir) {
526
600
  }
527
601
 
528
602
  // 批量 dump 全部 store 元数据(与 pwsh 版 storesDumpScript 同格式、
529
- // 同语义,见其注释):一条 shell 拿全部目录的 root.txt + index.json
603
+ // 同语义,见其注释):一条 shell 拿全部目录的 root.txt + index.json +
604
+ // lineage.json(PF-4)。
530
605
  // bash 3.2 兼容:数组 + += 均可用,glob 无匹配时字面量经 [ -d ] 过滤。
531
606
  // root.txt 经 tr 去掉可能的 CRLF 再拼单行,防标记结构被打乱。
532
607
  export function storesDumpScript(container, extraDirs) {
@@ -553,6 +628,9 @@ export function storesDumpScript(container, extraDirs) {
553
628
  ' echo INDEXBEGIN',
554
629
  ' cat "$d/index.json" 2>/dev/null',
555
630
  ' echo INDEXEND',
631
+ ' echo LINEAGEBEGIN',
632
+ ' cat "$d/lineage.json" 2>/dev/null',
633
+ ' echo LINEAGEEND',
556
634
  'done',
557
635
  'exit 0'
558
636
  )
@@ -33,6 +33,25 @@ export const MAX_FILE_BYTES = 104857600
33
33
  export const STALE_LOCK_MIN = 5
34
34
  export const HEARTBEAT_TTL_S = 900
35
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
+
36
55
  // 去除 PS 5.1 Set-Content -Encoding utf8 写出的 BOM:JSON 解析前必须剥掉,
37
56
  // 否则 JSON.parse 把 BOM 当正文首字符直接抛错。
38
57
  export function stripBom(text) {
@@ -52,16 +71,38 @@ function dropGitlinksBlock() {
52
71
  ].join('\n')
53
72
  }
54
73
 
55
- // 剔除超大文件:扫描加 SilentlyContinue 是因为 EAP=Stop 下个别不可访问
56
- // 子目录(杀软锁定、异常 ACL、损坏 junction)的非致命错误会被升级为终止,
57
- // 整条快照作废;本扫描只用于排除超大文件,漏看个别文件是 fail-open,可接受。
58
- // 阈值按调用注入(store.maxFileBytes,config 可调),不读模块常量。
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。
59
85
  // 依赖外层已定义的 $git/$g/$root。
60
86
  function oversizeBlock(maxBytes) {
61
87
  return [
62
- 'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + String(maxBytes || MAX_FILE_BYTES) + ' } | ForEach-Object {',
63
- " $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
64
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
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',
65
106
  '}'
66
107
  ].join('\n')
67
108
  }
@@ -78,6 +119,14 @@ function oversizeBlock(maxBytes) {
78
119
  // - base 基础排除表按调用注入(config.baseExcludes 可调),不硬编码。
79
120
  // - 依赖外层已定义的 $git/$g;被 ensureGit/snapshot/diff/rollback 复用,
80
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。
81
130
  function excludeSyncBlock(excludeFile, base) {
82
131
  // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
83
132
  // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
@@ -88,10 +137,21 @@ function excludeSyncBlock(excludeFile, base) {
88
137
  "if (Test-Path -LiteralPath $exFile) { $userPats = @(Get-Content -LiteralPath $exFile -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $t = $_.Trim(); $t -and -not $t.StartsWith('#') }) }",
89
138
  "$lines = @('') + @(" + baseList + ") + $userPats",
90
139
  "$exc = Join-Path $g 'info\\exclude'",
91
- 'Set-Content -LiteralPath $exc -Value $lines -Encoding utf8',
92
- '& $git -c core.quotePath=false --literal-pathspecs --git-dir=$g ls-files -i -c --exclude-from=$exc | ForEach-Object {',
93
- ' if ($_) { & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $_ }',
94
- '}'
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
+ '}',
95
155
  ].join('\n')
96
156
  }
97
157
 
@@ -160,6 +220,9 @@ export function migrateScript(src, dst) {
160
220
  // autocrlf=true 时的 LF/CRLF stderr 警告;addEmbeddedRepo=false:嵌套仓库
161
221
  // hint/warning 走 stderr,在 DSH shell(EAP=Stop)下会让整条脚本非零退出,
162
222
  // 必须在仓库级配置里静默掉。
223
+ // info/attributes 固化(issue #12,内容见 FIDELITY_ATTRS):info/ 由 git init
224
+ // 自带(info/exclude 模板,excludeSyncBlock 同样依赖),无需建目录;每次
225
+ // ensureGit 重写幂等,存量仓库升级后首次 init 自然补上。
163
226
  // 结尾回读 gc.stamp(maintenance.js 上次 gc 时间戳):让重启后的 gc 节流
164
227
  // 不归零——没有它,天天重启 DSH 的用户每次开机第一条消息都会触发一次
165
228
  // 全量 gc,纯浪费。
@@ -176,12 +239,35 @@ export function ensureGitScript(store, gitExe, base) {
176
239
  '& $git --git-dir=$g config core.longpaths true',
177
240
  '& $git --git-dir=$g config core.autocrlf false',
178
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",
179
244
  excludeSyncBlock(store.excludeFile, base),
180
245
  "$stamp = Join-Path $g 'gc.stamp'",
181
246
  "if (Test-Path -LiteralPath $stamp) { Write-Output ('GIT_OK ' + [String](Get-Content -LiteralPath $stamp -TotalCount 1 -ErrorAction SilentlyContinue)) } else { Write-Output 'GIT_OK' }"
182
247
  ].join('\n')
183
248
  }
184
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
+
185
271
  // 快照:git add -A 增量同步 index(.gitignore/exclude 语义由 git 统一处理),
186
272
  // write-tree 生成树、commit-tree 生成无父孤儿提交、tag 保对象可达。
187
273
  // 不做 parent 链、不修剪:像 TraeWork 一样保留全量历史,tag 永远可查。
@@ -197,6 +283,7 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
197
283
  heartbeatBlock(),
198
284
  dropGitlinksBlock(),
199
285
  excludeSyncBlock(store.excludeFile, base),
286
+ attrsMigrateBlock(),
200
287
  // fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
201
288
  // (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
202
289
  // 快照缺个别路径可接受,好过整条快照 fatal。退出码 ≥2 才是真 fatal
@@ -220,6 +307,10 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
220
307
  '$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
221
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()",
222
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)",
223
314
  "Write-Output 'SNAP_OK'"
224
315
  ].join('\n')
225
316
  }
@@ -231,7 +322,15 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
231
322
  // 改用 core.quotePath=false 让非 ASCII 路径原样输出,逐行按 TAB 解析。
232
323
  // 代价是文件名含换行的极端情况会解析错乱——概率可忽略,记录为已知限制。
233
324
  // (UTF-8 输出编码由 runShell 注入的 UTF8_PRELUDE 统一保证,此处不再重复设置。)
234
- export function diffScript(root, store, gitExe, tag, base) {
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))
235
334
  return [
236
335
  "$ErrorActionPreference = 'Stop'",
237
336
  '$git = ' + psq(gitExe),
@@ -274,7 +373,10 @@ export function diffScript(root, store, gitExe, tag, base) {
274
373
  ' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
275
374
  '}',
276
375
  '$sorted = @($result | Sort-Object rel)',
277
- 'Write-Output (ConvertTo-Json -InputObject $sorted -Depth 3 -Compress)'
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)"
278
380
  ].join('\n')
279
381
  }
280
382
 
@@ -492,22 +594,28 @@ export function purgeTagsScript(store, gitExe, tags) {
492
594
  ].join('\n')
493
595
  }
494
596
 
495
- // 任意长度文本写入(index.json / exclude.txt 共用):base64 以内联字面量
496
- // 传递时受 Windows 命令行 32767 字符硬上限约束(DSH pwsh 执行器把命令串
497
- // 作为 -Command 的单个 argv 元素 spawn),快照攒到几百条就超限 spawn 失败
498
- // ——按 20000 字符分块(分块循环在 store.js writeTextViaShell),首块
499
- // Set-Content 覆盖、续块 Add-Content 追加。piece 为当前块的 base64 解码
500
- // 表达式,first 决定覆盖还是追加。目录创建归调用方(writeExclude 的
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 的
501
607
  // mkdirScript 兜底 / index.json 的父目录在建仓时已存在)。
502
- export function fileWriteCmd(file, piece, first) {
503
- // EAP=Stop:Set-Content/Add-Content 是 cmdlet,失败(权限/磁盘满)在
504
- // 默认 EAP=Continue 下只写 error stream、退出码仍 0,半截写入被静默吞掉。
505
- // 显式 Stop 让失败抛终止错误,runShell 据此检测非零退出并抛错(H2)。
506
- const prelude = "$ErrorActionPreference = 'Stop'\n"
507
- if (first) {
508
- return prelude + piece + 'Set-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
509
- }
510
- return prelude + piece + 'Add-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
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')
511
619
  }
512
620
 
513
621
  // 原子 rename(H2):同卷 move 是 O(1) 元数据操作,把「已完整写完的 tmp」
@@ -531,9 +639,12 @@ export function lineageReadCmd(dir) {
531
639
  return 'Get-Content -LiteralPath ' + psq(dir + '\\lineage.json') + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
532
640
  }
533
641
 
534
- // 旧版项目内 blobs 目录清理(仅 home 存储可用时调用,见 store.js cleanupLegacy
642
+ // 旧版项目内 blobs 目录清理(仅 home 存储可用时调用,见 store.js cleanupLegacy)。
643
+ // -ErrorAction SilentlyContinue(PF-5):目标不存在是常态(极早期版本才有),
644
+ // 不容错的话 Remove-Item 抛错 → cleanupLegacy 永远走不到「成功」分支,
645
+ // legacyCleaned 标记失效,每次 init 都白跑一条进程。
535
646
  export function legacyRmScript(path) {
536
- return 'Remove-Item -Recurse -Force -LiteralPath ' + psq(path)
647
+ return 'Remove-Item -Recurse -Force -LiteralPath ' + psq(path) + ' -ErrorAction SilentlyContinue'
537
648
  }
538
649
 
539
650
  // exclude.txt 原文读取(设置页编辑用):-Raw 保留换行与空行结构,让用户
@@ -543,6 +654,26 @@ export function excludeReadCmd(file) {
543
654
  return 'Get-Content -LiteralPath ' + psq(file) + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
544
655
  }
545
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
+
546
677
  // 目录存在探测:输出定长 YES/NO 标记(与 posix 版逐字同语义),
547
678
  // JS 侧统一按 'YES' 判定,不依赖退出码——runShell 对非零退出直接抛错。
548
679
  export function dirExistsScript(dir) {
@@ -560,10 +691,26 @@ export function countObjectsScript(store, gitExe) {
560
691
  ].join('\n')
561
692
  }
562
693
 
563
- // 目录总大小(字节):降级存储时 git-dir 不是唯一占用(blobs 残留等),
564
- // 直接量目录最诚实。SilentlyContinue 容忍个别不可读文件。
694
+ // 目录总大小(字节):.NET 手动栈遍历(PF-3,容错语义同 oversizeBlock——
695
+ // 逐目录 try/catch 跳过不可访问目录)替代 Get-ChildItem -Recurse 逐文件
696
+ // 管道求和,GB 级快照库从秒级降到亚秒。$sum 初始化 [long]0 防溢出,目录
697
+ // 为空/全跳过时输出 0(旧实现的 .Sum 为 null,JS 侧 parseInt||0 兜底等价)。
565
698
  export function diskUsageScript(dir) {
566
- return "(Get-ChildItem -LiteralPath " + psq(dir) + " -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum"
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')
567
714
  }
568
715
 
569
716
  // 列目录下所有一级子目录全路径:manage/list 枚举 home 容器下的所有
@@ -574,13 +721,15 @@ export function listSubdirsScript(dir) {
574
721
  }
575
722
 
576
723
  // 批量 dump 全部 store 的元数据:一条 shell 拿「容器下所有子目录 +
577
- // 额外降级目录」的 root.txt 与 index.json 全文。为什么批量:旧实现
578
- // 每个目录 2 条 shell(读 index + 读 root.txt)串行跑,20 个目录就是
724
+ // 额外降级目录」的 root.txt、index.jsonlineage.json 全文。为什么批量:
725
+ // 旧实现每个目录 2 条 shell(读 index + 读 root.txt)串行跑,20 个目录就是
579
726
  // 40 次 PowerShell 冷启动(每次 0.3-1s)——快照管理列表 20 秒级慢的
580
- // 根因。输出定界格式(JS parseStoresDump 状态机解析):
727
+ // 根因。PF-4 起段内再带 lineage.json(manage lineage 原本对每个 root 串行
728
+ // 一条进程,并入后零新增)。输出定界格式(JS 侧 parseStoresDump 状态机解析):
581
729
  // ==DIR <目录>
582
730
  // ROOT <工作区路径或空>
583
731
  // INDEXBEGIN / index.json 原文 / INDEXEND
732
+ // LINEAGEBEGIN / lineage.json 原文 / LINEAGEEND
584
733
  // 标记行不会与内容混淆:root 路径不含换行(Windows 非法字符),JSON
585
734
  // 单行以 [ 或 { 起头、内部路径同样不含换行。
586
735
  export function storesDumpScript(container, extraDirs) {
@@ -615,6 +764,13 @@ export function storesDumpScript(container, extraDirs) {
615
764
  ' if ($j) { Write-Output $j.TrimEnd() }',
616
765
  ' }',
617
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'",
618
774
  '}',
619
775
  'exit 0'
620
776
  )