dsh-recall-plugin 2.3.0 → 2.3.2

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,638 +1,452 @@
1
- /**
2
- * dsh-recall-plugin bash 脚本模板(纯函数,无 ctx 依赖,POSIX 平台专用)
3
- *
4
- * 职责:Linux/macOS 下所有 shell 命令的 bash 脚本文本。与 scripts.pwsh.js
5
- * 导出同名接口,由 store.js 按 process.platform 选择。
6
- *
7
- * 硬约束(写每一段前先过一遍):
8
- * - macOS 系统 bash 3.2:禁用 declare -A / mapfile / ${var,,} 等 bash 4
9
- * 特性;关联数组需求全部下沉给 awk(POSIX awk 自带),排序交给 sort。
10
- * - 与 PowerShell 版不同,bash 按行解析时不存在「NUL 丢弃」问题,ls-files
11
- * 可以用 -z——但 diff/回退的清单对比仍走临时文件 + awk(bash 3.2 无映射
12
- * 结构),行内路径含 TAB/换行的极端情形与 Windows 版同为已知限制。
13
- * - 文件名比较、哈希输入全部按字节处理(LC_ALL=C,见 POSIX_PRELUDE),
14
- * 与 Node UTF-8 解码各司其职:bash 不转码,字节原样通过。
15
- */
16
-
17
- // 单引号字面量转义:bash 单引号串里不能出现单引号,标准手法是
18
- // 关闭引号 + \' 转义 + 重开('…'\''…'),杜绝变量展开与注入。
19
- export function psq(value) {
20
- return "'" + String(value).replace(/'/g, "'\\''") + "'"
21
- }
22
-
23
- // POSIX 统一前导:LC_ALL=C sort/awk 的路径排序按字节确定序(跨机器
24
- // 一致),也避免部分环境 locale 缺失时 git/perl 打 warning。bash 本身
25
- // 不转码 stdout,中文字节原样传给 Node UTF-8 解码,无 Windows
26
- // 的代码页问题。
27
- export const UTF8_PRELUDE = 'export LC_ALL=C'
28
-
29
- // 超大文件跳过阈值(字节),默认值与 config.js maxFileBytes 一致;
30
- // 实际生效值以 store.maxFileBytes(用户 config 可调)经 oversizeBlock 注入为准
31
- export const MAX_FILE_BYTES = 104857600
32
-
33
- // 失败清扫的 stale 锁阈值(分钟,M3):锁文件 mtime 超过该值才视为残留可清;
34
- // 更新的锁视为「有 git 操作正在进行」让路。本插件单条快照/回退的超时是
35
- // 10 分钟,阈值取其一半,保证本方超时遗留的锁(≥10 分钟)一定能被清。
36
- // pwsh 版必须同值(scripts-contract 钉);属内部安全策略常量,与
37
- // snapshots.js 的 FUSE_AFTER 同类,不走 Config。
38
- export const STALE_LOCK_MIN = 5
39
-
40
- // 心跳文件有效窗口(秒,M3):超过该时长的心跳视为失效实例(崩溃/重启
41
- // 遗留或长期空闲),不再阻止清扫。心跳随 ensureGit/快照刷新,活动中的
42
- // 实例窗口内必然有新心跳;长 git 操作(≤10 分钟)期间心跳写于操作开头,
43
- // 仍在窗口内。
44
- export const HEARTBEAT_TTL_S = 900
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
-
54
- // bash/cat 输出无 BOM;保留同名导出维持两套模板接口一致(幂等无害)
55
- export function stripBom(text) {
56
- return text.replace(/^\uFEFF/, '')
57
- }
58
-
59
- // 嵌套 git 仓库(工作区里的子项目自带 .git)会被 add -A 记成 gitlink
60
- // (160000);gitlink 残留在 index add -A 会 fatal,且对文件回退毫无
61
- // 意义——所以 add 前后各清一次,子仓库内容不进快照。
62
- // 依赖外层脚本已定义的 $git/$g;被 snapshot/diff/rollback 三处复用。
63
- function dropGitlinksBlock() {
64
- return [
65
- '"$git" --git-dir="$g" ls-files -z --stage | while IFS= read -r -d \'\' e; do',
66
- ' case "$e" in',
67
- " 160000\\ *) p=${e#*$'\\t'}; \"$git\" --literal-pathspecs --git-dir=\"$g\" update-index --force-remove -- \"$p\" ;;",
68
- ' esac',
69
- 'done'
70
- ].join('\n')
71
- }
72
-
73
- // 剔除超大文件:find -print0 + read -d '' 按字节安全遍历(文件名含换行
74
- // 也不怕);2>/dev/null 容忍个别不可访问子目录(杀软锁定、异常 ACL),
75
- // 漏看个别文件是 fail-open,可接受——与 pwsh 版同策略。
76
- // 阈值按调用注入(store.maxFileBytes,config 可调),不读模块常量。
77
- // 依赖外层已定义的 $git/$g/$root。
78
- // PF-9 合批:find 命中经管道剥前缀后 xargs -0 多路径合参——xargs 自适应
79
- // 批次(规避 ARG_MAX)等价 win32 侧显式 100 条/批;-0 保证路径不分裂;
80
- // xargs 失败/空输入 || true 兜住(fail-open 语义与逐条版一致,残留条目
81
- // 不进 index 的代价由下次快照幂等重试)。
82
- function oversizeBlock(maxBytes) {
83
- return [
84
- 'find "$root" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + 'c -print0 2>/dev/null | while IFS= read -r -d \'\' f; do',
85
- ' printf \'%s\\0\' "${f#"$root"/}"',
86
- "done | xargs -0 \"$git\" --literal-pathspecs --git-dir=\"$g\" update-index --force-remove -- 2>/dev/null || true",
87
- ].join('\n')
88
- }
89
-
90
- // 用户自定义排除同步:基础排除表 + 用户 exclude.txt 合并重写 info/exclude,
91
- // 再用 ls-files -i -c --exclude-from 找出「已被跟踪但命中排除」的条目清掉。
92
- // 与 pwsh 版同语义:只用 --exclude-from,不引入项目 .gitignore(--exclude-standard)
93
- // 的语义;放在 add -A 之前让排除先生效。read 循环里做 trim + 注释过滤,
94
- // 兼容 Windows 上编辑带 CRLF 的 exclude.txt。
95
- // base 基础排除表按调用注入(config.baseExcludes 可调),不硬编码。
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 空输入不执行)。
105
- function excludeSyncBlock(excludeFile, base) {
106
- // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
107
- // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
108
- const baseList = Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']
109
- const baseLines = baseList.join('\n') + '\n'
110
- return [
111
- 'ex_file=' + psq(excludeFile),
112
- 'exc="$g/info/exclude"',
113
- 'user_pats=""',
114
- 'if [ -f "$ex_file" ]; then',
115
- ' while IFS= read -r line || [ -n "$line" ]; do',
116
- " t=${line%$'\\r'}",
117
- ' t="${t#"${t%%[![:space:]]*}"}"; t="${t%"${t##*[![:space:]]}"}"',
118
- ' if [ -z "$t" ]; then continue; fi',
119
- ' case "$t" in \\#*) continue ;; esac',
120
- ' user_pats="$user_pats$t\\n"',
121
- ' done < "$ex_file"',
122
- 'fi',
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',
129
- ].join('\n')
130
- }
131
-
132
- // 心跳写入(M3):随 git 操作顺手把「宿主 PID + epoch 秒」写进 store 目录
133
- // (store.dir = git-dir 上两级),供对方实例的失败清扫判定「另一个 DSH 实例
134
- // 正在使用此快照库」。PID 在模板生成期取宿主进程的 process.pid,无需调用方
135
- // 传参。fail-open(|| true,set -e 下安全):心跳写失败绝不能连累快照本身
136
- // ——最坏退化为无心跳,清扫只剩新锁分级保护。
137
- // 依赖外层已定义的 $g;被 ensureGitScript / snapshotScript 复用。
138
- function heartbeatBlock() {
139
- return [
140
- 'hbf="$(dirname "$(dirname "$g")")/heartbeat"',
141
- 'printf \'%s %s\\n\' ' + psq(String(process.pid)) + ' "$(date +%s)" > "$hbf" 2>/dev/null || true'
142
- ].join('\n')
143
- }
144
-
145
- // 解析 git 可执行文件路径:bash PATH 找(POSIX 上 git 装了就在 PATH
146
- // 没有 Windows 那种四类安装位置的散装问题)
147
- export function resolveGitScript() {
148
- return [
149
- 'p=$(command -v git 2>/dev/null || true)',
150
- '[ -n "$p" ] && printf \'%s\\n\' "$p"',
151
- 'exit 0'
152
- ].join('\n')
153
- }
154
-
155
- // 探测 bash 侧的 home 基底:只回显 bash env 里的 $DSH_HOME(可能为空)。
156
- // 为什么不在这里回退 $HOME:DSH 的 bash 执行器会洗刷子进程的 DSH_* 变量
157
- // (dsh-subprocess scrubbedParentEnv),用户导出的 DSH_HOME 在 bash 里
158
- // 通常不可见——若在此回退 $HOME,Node 侧的字面量回退永远轮不到,
159
- // 「DSH_HOME 指到哪、快照就存哪」会失效。优先级与 pwsh 版对齐:
160
- // bash env 显式值 > Node 主进程 DSH_HOME > $HOME(os.homedir)。
161
- export function probeHomeScript() {
162
- return 'printf \'%s\' "${DSH_HOME:-}"'
163
- }
164
-
165
- export function mkdirScript(dir) {
166
- return 'mkdir -p -- ' + psq(dir)
167
- }
168
-
169
- // 旧版迁移:把降级时代落在项目内的影子仓库整体搬回 home 并删源目录
170
- export function migrateScript(src, dst) {
171
- return [
172
- 'set -e',
173
- 'src=' + psq(src),
174
- 'dst=' + psq(dst),
175
- 'if [ -e "$src/git" ]; then mv -f "$src/git" "$dst/git"; fi',
176
- 'if [ -e "$src/index.json" ]; then mv -f "$src/index.json" "$dst/index.json"; fi',
177
- 'rm -rf -- "$src"',
178
- 'echo MIGRATE_OK'
179
- ].join('\n')
180
- }
181
-
182
- // 旧快照容器一次性迁移(POSIX 专属,I24 漂移修复的存量数据兜底,输出四态
183
- // store.js resolvePosixHomeBase 消费):仅当「旧容器存在且新容器不存在」
184
- // 才整容器 mv——同卷(home 内部)rename 原子,无部分移动状态;容器级整移
185
- // 自然带上根级 exclude.txt,语义无损。BOTH_PRESENT(双容器并存)/MIGRATE_FAIL
186
- // 输出后不动任何数据。无 set -e(要靠分支输出状态而非中途退出);if 条件
187
- // 内的 && 链豁免 I16 约束(该坑只针对循环体与裸列表)。非 git 命令,不适用
188
- // g= 赋值 / RECALL_CLEANUP 哨兵约定。
189
- export function legacyHomeMigrateScript(homedir) {
190
- return [
191
- 'old=' + psq(homedir + '/dsh-recall-snapshots'),
192
- 'new=' + psq(homedir + '/.dsh/dsh-recall-snapshots'),
193
- 'if [ -d "$old" ] && [ ! -d "$new" ]; then',
194
- ' if mkdir -p -- "$(dirname "$new")" && mv -f -- "$old" "$new"; then echo MIGRATE_OK',
195
- ' else echo MIGRATE_FAIL; fi',
196
- 'elif [ -d "$old" ]; then echo BOTH_PRESENT',
197
- 'else echo OLD_ABSENT; fi'
198
- ].join('\n')
199
- }
200
-
201
- // 建立影子仓库 + 排除同步 + 回读 gc.stamp(语义与 pwsh 版一致,
202
- // 见 scripts.pwsh.js 同名函数注释)
203
- export function ensureGitScript(store, gitExe, base) {
204
- return [
205
- 'set -e',
206
- 'git=' + psq(gitExe),
207
- 'repo=' + psq(store.repo),
208
- 'g=' + psq(store.git),
209
- heartbeatBlock(),
210
- // 冷启动首消息快照与启动预热(或双实例)并发时,两个 git init 在空
211
- // repo 目录同跑,输家报 fatal: cannot mkdir <git>: File exists——窗口
212
- // 极小但首条消息的快照会因此丢失。git init 幂等:失败后 HEAD 已出现
213
- // 即同伴建成,视同成功继续;HEAD 也没有才是真失败,带错误退出(诊断
214
- // 不丢)。检查 HEAD 而非目录存在:半截目录也会被 init 补齐。pwsh 版
215
- // 无需同款改动——native 非零退出不抛(I14),输家继续跑 config
216
- // 同伴已建好 repo,竞态天然容忍,真失败由快照 add 的显式检查兜底。
217
- 'if [ ! -f "$g/HEAD" ]; then',
218
- ' init_log=$("$git" init "$repo" 2>&1) || {',
219
- ' if [ ! -f "$g/HEAD" ]; then printf \'%s\\n\' "$init_log" >&2; exit 1; fi',
220
- ' }',
221
- 'fi',
222
- '"$git" --git-dir="$g" config core.longpaths true',
223
- '"$git" --git-dir="$g" config core.autocrlf false',
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"',
229
- excludeSyncBlock(store.excludeFile, base),
230
- 'stamp="$g/gc.stamp"',
231
- 'if [ -f "$stamp" ]; then printf \'GIT_OK %s\\n\' "$(head -n1 "$stamp" 2>/dev/null)"; else echo GIT_OK; fi'
232
- ].join('\n')
233
- }
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
-
252
- // 快照:add -A → write-tree → commit-tree(孤儿提交)→ tag(语义同 pwsh 版)。
253
- // tag -f:事件重放/重发会产生重复 messageId,裸 tag 对已存在 tag fatal
254
- // 导致整条快照失败;-f 把 tag 指到最新提交,语义为「同一条消息取最新状态」。
255
- export function snapshotScript(root, store, gitExe, messageId, base) {
256
- return [
257
- 'set -e',
258
- 'git=' + psq(gitExe),
259
- 'g=' + psq(store.git),
260
- 'root=' + psq(root),
261
- heartbeatBlock(),
262
- dropGitlinksBlock(),
263
- excludeSyncBlock(store.excludeFile, base),
264
- attrsMigrateBlock(),
265
- // fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
266
- // 下「无法索引的路径」以退出码 1 结束但索引已落盘;≥2 才是真 fatal,
267
- // 显式退出让 runShell 抛错(set -e 对 add 非零本会终止,但 || rc=$?
268
- // 捕获后必须自检,否则 tolerated/fatal 无法区分)。stderr 合并进变量
269
- // fatal 时带回诊断与 SNAP_SKIP 提取。
270
- 'addrc=0',
271
- 'add_log=$("$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors 2>&1) || addrc=$?',
272
- 'if [ "$addrc" -ge 2 ]; then printf \'%s\\n\' "$add_log" >&2; exit "$addrc"; fi',
273
- "printf '%s\\n' \"$add_log\" | sed -n \"s/^error: unable to index file '\\(.*\\)'$/\\1/p\" | sort -u | while IFS= read -r sk; do",
274
- // 循环体用 if/fi 而非 && 列表:&& 列表条件为假时整条管道退出码为 1,
275
- // set -e 会把脚本杀掉(if 语句天然豁免)
276
- ' if [ -n "$sk" ]; then printf \'SNAP_SKIP %s\\n\' "$sk"; fi',
277
- 'done',
278
- dropGitlinksBlock(),
279
- oversizeBlock(store.maxFileBytes),
280
- 'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
281
- 'commit=$("$git" --git-dir="$g" -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree "$tree" -m ' + psq('snapshot ' + messageId) + ')',
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"',
286
- 'echo SNAP_OK'
287
- ].join('\n')
288
- }
289
-
290
- // 当前清单/目标树清单落临时文件:两处复用(diff 与 rollback)。
291
- // 关键前置链与 pwsh 版对齐——gitlink 清理 → 排除同步 → add -A → 再清
292
- // gitlink → 超大剔除——少了 add -A 的话 ls-files 读到的还是上一次快照的
293
- // 旧 index,「当前清单」永远等于目标 tag,diff 恒空(调试踩过的坑)。
294
- // 行格式:cur 为「mode sha stage<TAB>path」(取 sha=a[2]),target 为
295
- // 「mode type sha<TAB>path」(取 sha=a[3]);grep 滤掉 gitlink(160000)行,
296
- // 无匹配时退出码 1,set -e 下统一 || true。
297
- function collectListsBlock(store, gitExe, root, tag, base) {
298
- return [
299
- 'git=' + psq(gitExe),
300
- 'g=' + psq(store.git),
301
- 'root=' + psq(root),
302
- dropGitlinksBlock(),
303
- excludeSyncBlock(store.excludeFile, base),
304
- // fail-open add(语义见 snapshotScript 同款注释):diff/rollback 的当前
305
- // 清单来自这次 add 后的索引——被跳过的路径不进清单,diff 不显示、
306
- // rollback 删除清单也不会误删它们;≥2 显式退出防「旧索引假成功」
307
- 'addrc=0',
308
- '"$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors || addrc=$?',
309
- '[ "$addrc" -le 1 ] || exit "$addrc"',
310
- dropGitlinksBlock(),
311
- oversizeBlock(store.maxFileBytes),
312
- 'tmpc=' + psq(store.dir + '/diff-cur.$$'),
313
- 'tmpt=' + psq(store.dir + '/diff-tgt.$$'),
314
- '"$git" -c core.quotePath=false --git-dir="$g" --work-tree="$root" ls-files --stage | grep -v \'^160000 \' > "$tmpc" || true',
315
- '"$git" -c core.quotePath=false --git-dir="$g" ls-tree -r ' + psq(tag) + ' | grep -v \'^160000 \' > "$tmpt" || true'
316
- ].join('\n')
317
- }
318
-
319
- // diff:awk 一趟对比 cur/target(cur "mode sha<TAB>path" a[2],
320
- // target 侧 "mode type sha<TAB>path" 取 a[3]),输出 TSV「kind<TAB>path」
321
- // 逐行打印,Node 侧解析(不在 bash 里拼 JSON——没有 jq 依赖、
322
- // 转义路径的坑也一并消失)。sort -k2 按 path 确定序,与 pwsh 版对齐。
323
- // PF-1:末尾追加 write-tree + TREE 行(add 后的 index 树指纹,语义见 pwsh
324
- // 版同注释)。POSIX TSV 文本轻、无 ConvertTo-Json 序列化开销,不做
325
- // TOTAL/截断——全量输出,截断仍由 JS 侧 slice(与既有语义一致)。
326
- export function diffScript(root, store, gitExe, tag, base) {
327
- return [
328
- 'set -e -o pipefail',
329
- collectListsBlock(store, gitExe, root, tag, base),
330
- "trap 'rm -f \"$tmpc\" \"$tmpt\"' EXIT",
331
- 'awk -F\'\\t\' -v OFS=\'\\t\' \'.',
332
- ' FNR==1 { fidx++ }',
333
- ' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
334
- ' { split($1, a, " "); tgt[$2]=a[3] }',
335
- ' END {',
336
- ' for (p in cur) {',
337
- ' if (p in tgt) { if (tgt[p] != cur[p]) print "modified", p }',
338
- ' else print "added", p',
339
- ' }',
340
- ' for (p in tgt) if (!(p in cur)) print "restored", p',
341
- ' }',
342
- "' \"$tmpc\" \"$tmpt\" | sort -t$'\\t' -k2,2",
343
- 'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
344
- 'echo "TREE $tree"',
345
- 'exit 0'
346
- ].join('\n')
347
- }
348
-
349
- // 回退:archive | tar 直接管到工作区(无需 Windows zip 中转),
350
- // 空目标跳过;再删除「当前有、目标无」的文件(awk 求差集)。
351
- // pipefail 保证 git archive 失败时整条非零退出。
352
- // 删除侧失败必须响亮(F-G2):set -e 豁免 if 条件内的 rm 失败——若写成
353
- // `rm ... && deleted++` 裸链,rm 失败(权限等)被静默跳过、脚本仍输出
354
- // ROLLBACK_OK,半回退报成功、救援永不触发;改为 if/fi + 失败显式 exit 1,
355
- // pwsh 版「EAP=Stop Remove-Item 抛终止错误」对齐「删除失败即失败」
356
- // 的语义(见 scripts.pwsh.js rollbackScript 同位置注释)。
357
- export function rollbackScript(root, store, gitExe, tag, base) {
358
- return [
359
- 'set -e -o pipefail',
360
- collectListsBlock(store, gitExe, root, tag, base),
361
- "trap 'rm -f \"$tmpc\" \"$tmpt\"' EXIT",
362
- 'restored=$(wc -l < "$tmpt" | tr -d \' \')',
363
- 'if [ "$restored" -gt 0 ]; then',
364
- // -m(--touch):解包不恢复归档成员的 mtime(文件 mtime = 解包时刻)。
365
- // 必须如此:tar 默认保留归档内 mtime,而快照→篡改→回滚常在数秒内
366
- // 完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录碰撞,下一次
367
- // add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区内容与快照
368
- // 从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
369
- // Expand-Archive 天然把 mtime 设为解包时刻,无此问题;-m 让 tar 对齐。
370
- ' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
371
- 'fi',
372
- 'tmpd=' + psq(store.dir + '/diff-del.$$'),
373
- "trap 'rm -f \"$tmpc\" \"$tmpt\" \"$tmpd\"' EXIT",
374
- 'awk -F\'\\t\' \'',
375
- ' FNR==1 { fidx++ }',
376
- ' fidx==1 { cur[$2]=1; next }',
377
- ' { tgt[$2]=1 }',
378
- ' END { for (p in cur) if (!(p in tgt)) print p }',
379
- "' \"$tmpc\" \"$tmpt\" > \"$tmpd\"",
380
- 'deleted=0',
381
- 'while IFS= read -r p; do',
382
- // 循环体禁裸 && 链(AGENTS.md 已知坑同款规矩):&& 列表条件为假时整条
383
- // 退出码为 1,set -e 会把脚本杀掉;if 语句天然豁免。rm 失败必须响亮
384
- // exit 1——半回退假成功会让救援永不触发(F-G2)
385
- ' if [ -z "$p" ]; then continue; fi',
386
- ' if rm -f -- "$root/$p"; then deleted=$((deleted + 1)); else echo "RM_FAILED $p" >&2; exit 1; fi',
387
- 'done < "$tmpd"',
388
- 'echo "ROLLBACK_OK $deleted $restored"'
389
- ].join('\n')
390
- }
391
-
392
- // 回退失败救援(H1,语义见 pwsh 版同款注释):reset --hard 回安全快照。
393
- // 入参 tag 是完整 tag 名(snap- 前缀由调用侧 rescueRollback 拼齐)。
394
- // set -e 下 git 非零退出自然终止脚本,与 pwsh 版 $LASTEXITCODE 显式自检
395
- // 对齐「失败即抛」语义。
396
- export function rescueScript(root, store, gitExe, tag) {
397
- return [
398
- 'set -e',
399
- 'git=' + psq(gitExe),
400
- 'g=' + psq(store.git),
401
- 'root=' + psq(root),
402
- '"$git" --git-dir="$g" --work-tree="$root" reset --hard ' + psq(tag),
403
- 'echo RESCUE_OK'
404
- ].join('\n')
405
- }
406
-
407
- export function listTagsScript(store, gitExe) {
408
- return [
409
- 'set -e',
410
- 'git=' + psq(gitExe),
411
- 'g=' + psq(store.git),
412
- // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
413
- // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
414
- '[ -d "$g" ] || exit 0',
415
- '"$git" --git-dir="$g" tag -l \'snap-*\''
416
- ].join('\n')
417
- }
418
-
419
- // 孤儿重建用 tag 清单(带 creatordate):rebuildOrphans 据此恢复快照
420
- // 时间——只列 tag 名会让重建条目 time=0,管理列表时间前缀缺失、
421
- // retention/limits 按「最旧」误清。lightweight tag 无 tag 对象,
422
- // creatordate 即指向 commit 的提交日期。输出每行「<tag名> <秒级时间戳>」。
423
- export function listTagsWithTimeScript(store, gitExe) {
424
- return [
425
- 'set -e',
426
- 'git=' + psq(gitExe),
427
- 'g=' + psq(store.git),
428
- '[ -d "$g" ] || exit 0',
429
- '"$git" --git-dir="$g" for-each-ref --format=\'%(refname:short) %(creatordate:unix)\' \'refs/tags/snap-*\''
430
- ].join('\n')
431
- }
432
-
433
- // 定期 gc(语义同 pwsh 版);date +%s 写秒级时间戳,JS 侧 ×1000
434
- export function gcScript(store, gitExe) {
435
- return [
436
- 'set -e',
437
- 'git=' + psq(gitExe),
438
- 'g=' + psq(store.git),
439
- '"$git" --git-dir="$g" gc --quiet --prune=now',
440
- 'date +%s > "$g/gc.stamp"',
441
- 'echo GC_OK'
442
- ].join('\n')
443
- }
444
-
445
- // 快照失败后的残骸清理(语义同 pwsh 版,动机详见其注释):prune 以
446
- // refs + 暂存 index 为根删无引用对象,只清失败 add 的残骸、不碰 tag 快照
447
- export function pruneScript(store, gitExe) {
448
- return [
449
- 'set -e',
450
- 'git=' + psq(gitExe),
451
- 'g=' + psq(store.git),
452
- '"$git" --git-dir="$g" prune',
453
- 'echo PRUNE_OK'
454
- ].join('\n')
455
- }
456
-
457
- // 失败后的孤儿进程清扫 + stale 锁清理(issue #7 兜底 + issue #11 根因治理)。
458
- // 三级出口(M3):
459
- // 1. CLEANUP_OTHER_INSTANCE <pid>——心跳文件(宿主 PID + epoch 秒)显示另一
460
- // 个存活实例正在使用同一快照库:直接让路,不杀进程、不动锁。这是两个
461
- // DSH 实例并发互踩(一方清扫误杀另一方活跃 git → 对方也失败 → 循环)的
462
- // 根治;心跳由 ensureGit/snapshotScript 随操作刷新,TTL 外视为失效。
463
- // 2. CLEANUP_SKIPPED_FRESH_LOCK——存在 STALE_LOCK_MIN 分钟内的新锁(疑似有
464
- // git 操作正在进行):同样让路,锁陈旧后下次失败自然进入第 3 级。
465
- // 3. CLEANUP_DONE——两级保护均未命中,按原有行为清孤儿进程与 stale 锁。
466
- // POSIX 版差异:pgrep -f 按扩展正则匹配整条命令行——路径元字符([、+ 等)
467
- // 会让模式失配,清扫静默失效,属安全降级(等价于本兜底加入前的行为);
468
- // 标记在运行期拼接,本脚本自身命令行里只有未展开的 $g 字面量,不会自杀。
469
- // 全程无 set -e + 逐步容错:清扫自身的失败不能抛。mtime 判定用 find -mmin
470
- // 而非 stat:GNU/BSD stat 参数不同(macOS bash 3.2 约束),find 的 -mmin
471
- // 两平台语义一致。
472
- export function killOrphansScript(gitDir) {
473
- return [
474
- '# RECALL_CLEANUP',
475
- 'g=' + psq(gitDir),
476
- 'hbf="$(dirname "$(dirname "$g")")/heartbeat"',
477
- 'if [ -f "$hbf" ]; then',
478
- ' hbl=$(head -n1 "$hbf" 2>/dev/null | tr -d \'\\r\')',
479
- ' hbp=${hbl%% *}',
480
- ' hbt=${hbl#* }',
481
- " case \"$hbp\" in ''|*[!0-9]*) hbp='' ;; esac",
482
- " case \"$hbt\" in ''|*[!0-9]*) hbt=0 ;; esac",
483
- ' hbage=$(( $(date +%s) - hbt ))',
484
- ' if [ -n "$hbp" ] && [ "$hbp" != ' + psq(String(process.pid)) + ' ] && [ "$hbage" -ge 0 ] && [ "$hbage" -lt ' + HEARTBEAT_TTL_S + ' ]; then',
485
- ' if kill -0 "$hbp" 2>/dev/null; then',
486
- ' echo "CLEANUP_OTHER_INSTANCE $hbp"',
487
- ' exit 0',
488
- ' fi',
489
- ' fi',
490
- 'fi',
491
- "fresh=$(find \"$g\" -maxdepth 1 -type f \\( -name '*.lock' -o -name 'gc.pid' \\) -mmin -" + STALE_LOCK_MIN + " 2>/dev/null; find \"$g/refs\" -type f -name '*.lock' -mmin -" + STALE_LOCK_MIN + ' 2>/dev/null)',
492
- 'if [ -n "$fresh" ]; then',
493
- ' echo CLEANUP_SKIPPED_FRESH_LOCK',
494
- ' exit 0',
495
- 'fi',
496
- 'marker="--git-dir=$g"',
497
- 'for p in $(pgrep -f -- "$marker" 2>/dev/null); do',
498
- ' [ "$p" = "$$" ] && continue',
499
- ' kill "$p" 2>/dev/null || true',
500
- 'done',
501
- // 锁清单对齐 pwsh 版:index.lock 是 add/checkout 持久锁,其余是
502
- // gc/tag/pack 链路残留;refs 下 per-ref 锁用 find 兜底(只删陈旧锁,
503
- // 新锁已被上方分级保护拦下)
504
- 'rm -f "$g/index.lock" "$g/config.lock" "$g/HEAD.lock" "$g/gc.pid" "$g/packed-refs.lock" "$g/shallow.lock" 2>/dev/null || true',
505
- 'find "$g/refs" -type f -name "*.lock" -mmin +' + STALE_LOCK_MIN + ' -delete 2>/dev/null || true',
506
- 'echo CLEANUP_DONE'
507
- ].join('\n')
508
- }
509
-
510
- // 删除指定快照 tag(会话已删联动清理用):tag -d 对不存在 tag 非零退出,
511
- // || true 吞掉——best-effort,残留 tag 由下次清理幂等收尾(同 pwsh 版)
512
- export function purgeTagsScript(store, gitExe, tags) {
513
- return [
514
- 'git=' + psq(gitExe),
515
- 'g=' + psq(store.git),
516
- '"$git" --git-dir="$g" tag -d ' + tags.map((t) => psq(t)).join(' ') + ' >/dev/null 2>&1 || true',
517
- 'echo PURGE_DONE'
518
- ].join('\n')
519
- }
520
-
521
- // 原子 rename(H2,语义见 pwsh 版同款注释):mv -f 同卷 move 为 O(1) 元
522
- // 数据操作;也用于 loadIndex 把损坏索引改名 .corrupt-<ts> 保留现场。
523
- export function renameFileCmd(src, dst) {
524
- return 'mv -f -- ' + psq(src) + ' ' + psq(dst)
525
- }
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
-
534
- // 索引读取(写入走 stdin:见 snapshots.js saveIndex 的 POSIX 分支,
535
- // 不经命令行传参,天然没有 32767/128KB argv 上限问题)
536
- export function indexReadCmd(dir) {
537
- return 'cat ' + psq(dir + '/index.json') + ' 2>/dev/null || true'
538
- }
539
-
540
- // fork lineage 读取(F1,语义见 pwsh 版同款注释):lineage.json 与 index.json
541
- // 同层、原子写;缺失文件输出空串。
542
- export function lineageReadCmd(dir) {
543
- return 'cat ' + psq(dir + '/lineage.json') + ' 2>/dev/null || true'
544
- }
545
-
546
- // 旧版项目内 blobs 目录清理(仅 home 存储可用时调用)
547
- export function legacyRmScript(path) {
548
- return 'rm -rf -- ' + psq(path)
549
- }
550
-
551
- // exclude.txt 原文读取(设置页编辑用):缺失文件 cat 报错走 2>/dev/null ||
552
- // true 吞掉输出空串——与 pwsh 版同语义,按「尚未配置」处理;写入不走
553
- // 模板函数,调用方直接 cat > file + stdin(同 saveIndex 的 POSIX 分支)。
554
- export function excludeReadCmd(file) {
555
- return 'cat ' + psq(file) + ' 2>/dev/null || true'
556
- }
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
-
574
- // 目录存在探测:YES/NO 定长标记与 pwsh 版逐字同语义(容器路径本身在
575
- // JS 侧解析,POSIX 不需要 homeContainerScript 的 shell 版)。
576
- export function dirExistsScript(dir) {
577
- return '[ -d ' + psq(dir) + ' ] && echo YES || echo NO'
578
- }
579
-
580
- // 影子仓库磁盘占用(设置页快照管理卡片用,语义同 pwsh 版)
581
- export function countObjectsScript(store, gitExe) {
582
- return [
583
- 'git=' + psq(gitExe),
584
- 'g=' + psq(store.git),
585
- '"$git" --git-dir="$g" count-objects -v'
586
- ].join('\n')
587
- }
588
-
589
- // 目录总大小(字节):du -sk 取 KiB 再 ×1024;macOS/BSD du 与 GNU du
590
- // 对 -sk 的输出格式一致("大小<TAB>路径"),awk 取首列最稳。
591
- export function diskUsageScript(dir) {
592
- return 'du -sk ' + psq(dir) + ' 2>/dev/null | awk \'{print $1 * 1024}\''
593
- }
594
-
595
- // 列目录下所有一级子目录全路径:manage/list 枚举 home 容器下的所有
596
- // 哈希子目录用(每个子目录是一个工作区的 store)。find -maxdepth 1
597
- // 限定深度避免递归,2>/dev/null 容忍个别不可读条目。
598
- export function listSubdirsScript(dir) {
599
- return 'find ' + psq(dir) + ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null'
600
- }
601
-
602
- // 批量 dump 全部 store 元数据(与 pwsh 版 storesDumpScript 同格式、
603
- // 同语义,见其注释):一条 shell 拿全部目录的 root.txt + index.json +
604
- // lineage.json(PF-4)。
605
- // bash 3.2 兼容:数组 + += 均可用,glob 无匹配时字面量经 [ -d ] 过滤。
606
- // root.txt 经 tr 去掉可能的 CRLF 再拼单行,防标记结构被打乱。
607
- export function storesDumpScript(container, extraDirs) {
608
- const lines = ['set -e', 'dirs=()']
609
- if (container) {
610
- lines.push('base=' + psq(container))
611
- lines.push('if [ -d "$base" ]; then')
612
- lines.push(' for d in "$base"/*/; do')
613
- // if 而不是 [ ] && :glob 无匹配时条件为假,&& 链返回非零会触发 set -e
614
- lines.push(' if [ -d "$d" ]; then dirs+=("${d%/}"); fi')
615
- lines.push(' done')
616
- lines.push('fi')
617
- }
618
- for (const d of extraDirs || []) lines.push('dirs+=(' + psq(d) + ')')
619
- lines.push(
620
- 'for d in "${dirs[@]}"; do',
621
- ' [ -d "$d" ] || continue',
622
- ' echo "==DIR $d"',
623
- ' if [ -f "$d/root.txt" ]; then',
624
- ' printf "ROOT %s\\n" "$(cat "$d/root.txt" 2>/dev/null | tr -d \'\\r\\n\')"',
625
- ' else',
626
- ' echo "ROOT "',
627
- ' fi',
628
- ' echo INDEXBEGIN',
629
- ' cat "$d/index.json" 2>/dev/null',
630
- ' echo INDEXEND',
631
- ' echo LINEAGEBEGIN',
632
- ' cat "$d/lineage.json" 2>/dev/null',
633
- ' echo LINEAGEEND',
634
- 'done',
635
- 'exit 0'
636
- )
637
- return lines.join('\n')
638
- }
1
+ function psq(value) {
2
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
3
+ }
4
+ const UTF8_PRELUDE = "export LC_ALL=C";
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 -z --stage | while IFS= read -r -d '' e; do`,
15
+ ' case "$e" in',
16
+ ` 160000\\ *) p=\${e#*$'\\t'}; "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- "$p" ;;`,
17
+ " esac",
18
+ "done"
19
+ ].join("\n");
20
+ }
21
+ function oversizeBlock(maxBytes) {
22
+ return [
23
+ 'find "$root" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + "c -print0 2>/dev/null | while IFS= read -r -d '' f; do",
24
+ ` printf '%s\\0' "\${f#"$root"/}"`,
25
+ 'done | xargs -0 "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- 2>/dev/null || true'
26
+ ].join("\n");
27
+ }
28
+ function excludeSyncBlock(excludeFile, base) {
29
+ const baseList = Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"];
30
+ const baseLines = baseList.join("\n") + "\n";
31
+ return [
32
+ "ex_file=" + psq(excludeFile),
33
+ 'exc="$g/info/exclude"',
34
+ 'user_pats=""',
35
+ 'if [ -f "$ex_file" ]; then',
36
+ ' while IFS= read -r line || [ -n "$line" ]; do',
37
+ " t=${line%$'\\r'}",
38
+ ' t="${t#"${t%%[![:space:]]*}"}"; t="${t%"${t##*[![:space:]]}"}"',
39
+ ' if [ -z "$t" ]; then continue; fi',
40
+ ' case "$t" in \\#*) continue ;; esac',
41
+ ' user_pats="$user_pats$t\\n"',
42
+ ' done < "$ex_file"',
43
+ "fi",
44
+ "new_exc=$(printf '\\n" + baseLines.replace(/\\/g, "\\\\").replace(/%/g, "%%") + `%b' "$user_pats")`,
45
+ 'old_exc=$(cat "$exc" 2>/dev/null || true)',
46
+ 'if [ "$new_exc" != "$old_exc" ]; then',
47
+ ` printf '%s\\n' "$new_exc" > "$exc"`,
48
+ ' "$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',
49
+ "fi"
50
+ ].join("\n");
51
+ }
52
+ function heartbeatBlock() {
53
+ return [
54
+ 'hbf="$(dirname "$(dirname "$g")")/heartbeat"',
55
+ "printf '%s %s\\n' " + psq(String(process.pid)) + ' "$(date +%s)" > "$hbf" 2>/dev/null || true'
56
+ ].join("\n");
57
+ }
58
+ function resolveGitScript() {
59
+ return [
60
+ "p=$(command -v git 2>/dev/null || true)",
61
+ `[ -n "$p" ] && printf '%s\\n' "$p"`,
62
+ "exit 0"
63
+ ].join("\n");
64
+ }
65
+ function probeHomeScript() {
66
+ return `printf '%s' "\${DSH_HOME:-}"`;
67
+ }
68
+ function mkdirScript(dir) {
69
+ return "mkdir -p -- " + psq(dir);
70
+ }
71
+ function migrateScript(src, dst) {
72
+ return [
73
+ "set -e",
74
+ "src=" + psq(src),
75
+ "dst=" + psq(dst),
76
+ 'if [ -e "$src/git" ]; then mv -f "$src/git" "$dst/git"; fi',
77
+ 'if [ -e "$src/index.json" ]; then mv -f "$src/index.json" "$dst/index.json"; fi',
78
+ 'rm -rf -- "$src"',
79
+ "echo MIGRATE_OK"
80
+ ].join("\n");
81
+ }
82
+ function legacyHomeMigrateScript(homedir) {
83
+ return [
84
+ "old=" + psq(homedir + "/dsh-recall-snapshots"),
85
+ "new=" + psq(homedir + "/.dsh/dsh-recall-snapshots"),
86
+ 'if [ -d "$old" ] && [ ! -d "$new" ]; then',
87
+ ' if mkdir -p -- "$(dirname "$new")" && mv -f -- "$old" "$new"; then echo MIGRATE_OK',
88
+ " else echo MIGRATE_FAIL; fi",
89
+ 'elif [ -d "$old" ]; then echo BOTH_PRESENT',
90
+ "else echo OLD_ABSENT; fi"
91
+ ].join("\n");
92
+ }
93
+ function ensureGitScript(store, gitExe, base) {
94
+ return [
95
+ "set -e",
96
+ "git=" + psq(gitExe),
97
+ "repo=" + psq(store.repo),
98
+ "g=" + psq(store.git),
99
+ heartbeatBlock(),
100
+ // 冷启动首消息快照与启动预热(或双实例)并发时,两个 git init 在空
101
+ // repo 目录同跑,输家报 fatal: cannot mkdir <git>: File exists——窗口
102
+ // 极小但首条消息的快照会因此丢失。git init 幂等:失败后 HEAD 已出现
103
+ // 即同伴建成,视同成功继续;HEAD 也没有才是真失败,带错误退出(诊断
104
+ // 不丢)。检查 HEAD 而非目录存在:半截目录也会被 init 补齐。pwsh
105
+ // 无需同款改动——native 非零退出不抛(I14),输家继续跑 config 时
106
+ // 同伴已建好 repo,竞态天然容忍,真失败由快照 add 的显式检查兜底。
107
+ 'if [ ! -f "$g/HEAD" ]; then',
108
+ ' init_log=$("$git" init "$repo" 2>&1) || {',
109
+ ` if [ ! -f "$g/HEAD" ]; then printf '%s\\n' "$init_log" >&2; exit 1; fi`,
110
+ " }",
111
+ "fi",
112
+ '"$git" --git-dir="$g" config core.longpaths true',
113
+ '"$git" --git-dir="$g" config core.autocrlf false',
114
+ '"$git" --git-dir="$g" config advice.addEmbeddedRepo false',
115
+ // 属性固化(issue #12,内容见 FIDELITY_ATTRS):info/ git init 自带
116
+ // (info/exclude 模板,excludeSyncBlock 同样依赖),无需建目录;每次
117
+ // ensureGit 重写幂等,存量仓库升级后首次 init 自然补上。
118
+ "printf " + psq(FIDELITY_ATTRS + "\n") + ' > "$g/info/attributes"',
119
+ excludeSyncBlock(store.excludeFile, base),
120
+ 'stamp="$g/gc.stamp"',
121
+ `if [ -f "$stamp" ]; then printf 'GIT_OK %s\\n' "$(head -n1 "$stamp" 2>/dev/null)"; else echo GIT_OK; fi`
122
+ ].join("\n");
123
+ }
124
+ function attrsMigrateBlock() {
125
+ return [
126
+ 'mig_stamp="$g/attrs-v1.stamp"',
127
+ 'if [ ! -f "$mig_stamp" ]; then',
128
+ " migrc=0",
129
+ ` "$git" --git-dir="$g" --work-tree="$root" add --renormalize --ignore-errors -- ':(top)' >/dev/null 2>&1 || migrc=$?`,
130
+ ` if [ "$migrc" -le 1 ]; then printf '1\\n' > "$mig_stamp" 2>/dev/null || true; fi`,
131
+ "fi"
132
+ ].join("\n");
133
+ }
134
+ function snapshotScript(root, store, gitExe, messageId, base) {
135
+ return [
136
+ "set -e",
137
+ "git=" + psq(gitExe),
138
+ "g=" + psq(store.git),
139
+ "root=" + psq(root),
140
+ heartbeatBlock(),
141
+ dropGitlinksBlock(),
142
+ excludeSyncBlock(store.excludeFile, base),
143
+ attrsMigrateBlock(),
144
+ // fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
145
+ // 下「无法索引的路径」以退出码 1 结束但索引已落盘;≥2 才是真 fatal
146
+ // 显式退出让 runShell 抛错(set -e 对 add 非零本会终止,但 || rc=$?
147
+ // 捕获后必须自检,否则 tolerated/fatal 无法区分)。stderr 合并进变量
148
+ // 供 fatal 时带回诊断与 SNAP_SKIP 提取。
149
+ "addrc=0",
150
+ 'add_log=$("$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors 2>&1) || addrc=$?',
151
+ `if [ "$addrc" -ge 2 ]; then printf '%s\\n' "$add_log" >&2; exit "$addrc"; fi`,
152
+ `printf '%s\\n' "$add_log" | sed -n "s/^error: unable to index file '\\(.*\\)'$/\\1/p" | sort -u | while IFS= read -r sk; do`,
153
+ // 循环体用 if/fi 而非 && 列表:&& 列表条件为假时整条管道退出码为 1,
154
+ // set -e 会把脚本杀掉(if 语句天然豁免)
155
+ ` if [ -n "$sk" ]; then printf 'SNAP_SKIP %s\\n' "$sk"; fi`,
156
+ "done",
157
+ dropGitlinksBlock(),
158
+ oversizeBlock(store.maxFileBytes),
159
+ 'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
160
+ 'commit=$("$git" --git-dir="$g" -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree "$tree" -m ' + psq("snapshot " + messageId) + ")",
161
+ '"$git" --git-dir="$g" tag -f ' + psq("snap-" + messageId) + ' "$commit" >/dev/null',
162
+ // PF-1:TREE 行随 SNAP_OK 回传 add -A 之后的 index 树指纹(语义见 pwsh 版
163
+ // 同名注释)——execute 与 preview 指纹比对判 STALE,免整条重复 diff
164
+ 'echo "TREE $tree"',
165
+ "echo SNAP_OK"
166
+ ].join("\n");
167
+ }
168
+ function collectListsBlock(store, gitExe, root, tag, base) {
169
+ return [
170
+ "git=" + psq(gitExe),
171
+ "g=" + psq(store.git),
172
+ "root=" + psq(root),
173
+ dropGitlinksBlock(),
174
+ excludeSyncBlock(store.excludeFile, base),
175
+ // fail-open add(语义见 snapshotScript 同款注释):diff/rollback 的当前
176
+ // 清单来自这次 add 后的索引——被跳过的路径不进清单,diff 不显示、
177
+ // rollback 删除清单也不会误删它们;≥2 显式退出防「旧索引假成功」
178
+ "addrc=0",
179
+ '"$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors || addrc=$?',
180
+ '[ "$addrc" -le 1 ] || exit "$addrc"',
181
+ dropGitlinksBlock(),
182
+ oversizeBlock(store.maxFileBytes),
183
+ "tmpc=" + psq(store.dir + "/diff-cur.$$"),
184
+ "tmpt=" + psq(store.dir + "/diff-tgt.$$"),
185
+ `"$git" -c core.quotePath=false --git-dir="$g" --work-tree="$root" ls-files --stage | grep -v '^160000 ' > "$tmpc" || true`,
186
+ '"$git" -c core.quotePath=false --git-dir="$g" ls-tree -r ' + psq(tag) + ` | grep -v '^160000 ' > "$tmpt" || true`
187
+ ].join("\n");
188
+ }
189
+ function diffScript(root, store, gitExe, tag, base) {
190
+ return [
191
+ "set -e -o pipefail",
192
+ collectListsBlock(store, gitExe, root, tag, base),
193
+ `trap 'rm -f "$tmpc" "$tmpt"' EXIT`,
194
+ "awk -F'\\t' -v OFS='\\t' '",
195
+ " FNR==1 { fidx++ }",
196
+ ' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
197
+ ' { split($1, a, " "); tgt[$2]=a[3] }',
198
+ " END {",
199
+ " for (p in cur) {",
200
+ ' if (p in tgt) { if (tgt[p] != cur[p]) print "modified", p }',
201
+ ' else print "added", p',
202
+ " }",
203
+ ' for (p in tgt) if (!(p in cur)) print "restored", p',
204
+ " }",
205
+ `' "$tmpc" "$tmpt" | sort -t$'\\t' -k2,2`,
206
+ 'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
207
+ 'echo "TREE $tree"',
208
+ "exit 0"
209
+ ].join("\n");
210
+ }
211
+ function rollbackScript(root, store, gitExe, tag, base) {
212
+ return [
213
+ "set -e -o pipefail",
214
+ collectListsBlock(store, gitExe, root, tag, base),
215
+ `trap 'rm -f "$tmpc" "$tmpt"' EXIT`,
216
+ `restored=$(wc -l < "$tmpt" | tr -d ' ')`,
217
+ 'if [ "$restored" -gt 0 ]; then',
218
+ // -m(--touch):解包不恢复归档成员的 mtime(文件 mtime = 解包时刻)。
219
+ // 必须如此:tar 默认保留归档内 mtime,而快照→篡改→回滚常在数秒内
220
+ // 完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录碰撞,下一次
221
+ // add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区内容与快照
222
+ // 从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
223
+ // Expand-Archive 天然把 mtime 设为解包时刻,无此问题;-m 让 tar 对齐。
224
+ ' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
225
+ "fi",
226
+ "tmpd=" + psq(store.dir + "/diff-del.$$"),
227
+ `trap 'rm -f "$tmpc" "$tmpt" "$tmpd"' EXIT`,
228
+ "awk -F'\\t' '",
229
+ " FNR==1 { fidx++ }",
230
+ " fidx==1 { cur[$2]=1; next }",
231
+ " { tgt[$2]=1 }",
232
+ " END { for (p in cur) if (!(p in tgt)) print p }",
233
+ `' "$tmpc" "$tmpt" > "$tmpd"`,
234
+ "deleted=0",
235
+ "while IFS= read -r p; do",
236
+ // 循环体禁裸 && 链(AGENTS.md 已知坑同款规矩):&& 列表条件为假时整条
237
+ // 退出码为 1,set -e 会把脚本杀掉;if 语句天然豁免。rm 失败必须响亮
238
+ // exit 1——半回退假成功会让救援永不触发(F-G2)
239
+ ' if [ -z "$p" ]; then continue; fi',
240
+ ' if rm -f -- "$root/$p"; then deleted=$((deleted + 1)); else echo "RM_FAILED $p" >&2; exit 1; fi',
241
+ 'done < "$tmpd"',
242
+ 'echo "ROLLBACK_OK $deleted $restored"'
243
+ ].join("\n");
244
+ }
245
+ function rescueScript(root, store, gitExe, tag) {
246
+ return [
247
+ "set -e",
248
+ "git=" + psq(gitExe),
249
+ "g=" + psq(store.git),
250
+ "root=" + psq(root),
251
+ '"$git" --git-dir="$g" --work-tree="$root" reset --hard ' + psq(tag),
252
+ "echo RESCUE_OK"
253
+ ].join("\n");
254
+ }
255
+ function listTagsScript(store, gitExe) {
256
+ return [
257
+ "set -e",
258
+ "git=" + psq(gitExe),
259
+ "g=" + psq(store.git),
260
+ // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
261
+ // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
262
+ '[ -d "$g" ] || exit 0',
263
+ `"$git" --git-dir="$g" tag -l 'snap-*'`
264
+ ].join("\n");
265
+ }
266
+ function listTagsWithTimeScript(store, gitExe) {
267
+ return [
268
+ "set -e",
269
+ "git=" + psq(gitExe),
270
+ "g=" + psq(store.git),
271
+ '[ -d "$g" ] || exit 0',
272
+ `"$git" --git-dir="$g" for-each-ref --format='%(refname:short) %(creatordate:unix)' 'refs/tags/snap-*'`
273
+ ].join("\n");
274
+ }
275
+ function gcScript(store, gitExe) {
276
+ return [
277
+ "set -e",
278
+ "git=" + psq(gitExe),
279
+ "g=" + psq(store.git),
280
+ '"$git" --git-dir="$g" gc --quiet --prune=now',
281
+ 'date +%s > "$g/gc.stamp"',
282
+ "echo GC_OK"
283
+ ].join("\n");
284
+ }
285
+ function pruneScript(store, gitExe) {
286
+ return [
287
+ "set -e",
288
+ "git=" + psq(gitExe),
289
+ "g=" + psq(store.git),
290
+ '"$git" --git-dir="$g" prune',
291
+ "echo PRUNE_OK"
292
+ ].join("\n");
293
+ }
294
+ function killOrphansScript(gitDir) {
295
+ return [
296
+ "# RECALL_CLEANUP",
297
+ "g=" + psq(gitDir),
298
+ 'hbf="$(dirname "$(dirname "$g")")/heartbeat"',
299
+ 'if [ -f "$hbf" ]; then',
300
+ ` hbl=$(head -n1 "$hbf" 2>/dev/null | tr -d '\\r')`,
301
+ " hbp=${hbl%% *}",
302
+ " hbt=${hbl#* }",
303
+ ` case "$hbp" in ''|*[!0-9]*) hbp='' ;; esac`,
304
+ ` case "$hbt" in ''|*[!0-9]*) hbt=0 ;; esac`,
305
+ " hbage=$(( $(date +%s) - hbt ))",
306
+ ' if [ -n "$hbp" ] && [ "$hbp" != ' + psq(String(process.pid)) + ' ] && [ "$hbage" -ge 0 ] && [ "$hbage" -lt ' + HEARTBEAT_TTL_S + " ]; then",
307
+ ' if kill -0 "$hbp" 2>/dev/null; then',
308
+ ' echo "CLEANUP_OTHER_INSTANCE $hbp"',
309
+ " exit 0",
310
+ " fi",
311
+ " fi",
312
+ "fi",
313
+ `fresh=$(find "$g" -maxdepth 1 -type f \\( -name '*.lock' -o -name 'gc.pid' \\) -mmin -` + STALE_LOCK_MIN + ` 2>/dev/null; find "$g/refs" -type f -name '*.lock' -mmin -` + STALE_LOCK_MIN + " 2>/dev/null)",
314
+ 'if [ -n "$fresh" ]; then',
315
+ " echo CLEANUP_SKIPPED_FRESH_LOCK",
316
+ " exit 0",
317
+ "fi",
318
+ 'marker="--git-dir=$g"',
319
+ 'for p in $(pgrep -f -- "$marker" 2>/dev/null); do',
320
+ ' [ "$p" = "$$" ] && continue',
321
+ ' kill "$p" 2>/dev/null || true',
322
+ "done",
323
+ // 锁清单对齐 pwsh 版:index.lock add/checkout 持久锁,其余是
324
+ // gc/tag/pack 链路残留;refs per-ref 锁用 find 兜底(只删陈旧锁,
325
+ // 新锁已被上方分级保护拦下)
326
+ 'rm -f "$g/index.lock" "$g/config.lock" "$g/HEAD.lock" "$g/gc.pid" "$g/packed-refs.lock" "$g/shallow.lock" 2>/dev/null || true',
327
+ 'find "$g/refs" -type f -name "*.lock" -mmin +' + STALE_LOCK_MIN + " -delete 2>/dev/null || true",
328
+ "echo CLEANUP_DONE"
329
+ ].join("\n");
330
+ }
331
+ function purgeTagsScript(store, gitExe, tags) {
332
+ return [
333
+ "git=" + psq(gitExe),
334
+ "g=" + psq(store.git),
335
+ '"$git" --git-dir="$g" tag -d ' + tags.map((t) => psq(t)).join(" ") + " >/dev/null 2>&1 || true",
336
+ "echo PURGE_DONE"
337
+ ].join("\n");
338
+ }
339
+ function renameFileCmd(src, dst) {
340
+ return "mv -f -- " + psq(src) + " " + psq(dst);
341
+ }
342
+ function fileWriteStdinCmd(file) {
343
+ return "cat > " + psq(file);
344
+ }
345
+ function indexReadCmd(dir) {
346
+ return "cat " + psq(dir + "/index.json") + " 2>/dev/null || true";
347
+ }
348
+ function lineageReadCmd(dir) {
349
+ return "cat " + psq(dir + "/lineage.json") + " 2>/dev/null || true";
350
+ }
351
+ function legacyRmScript(path) {
352
+ return "rm -rf -- " + psq(path);
353
+ }
354
+ function excludeReadCmd(file) {
355
+ return "cat " + psq(file) + " 2>/dev/null || true";
356
+ }
357
+ function excludeDumpScript(files) {
358
+ const lines = [];
359
+ for (const f of files || []) {
360
+ const q = psq(f);
361
+ lines.push(
362
+ "printf 'EXCLBEGIN %s\\n' " + q,
363
+ "if [ -f " + q + " ]; then base64 " + q + " 2>/dev/null | tr -d '\\n'; fi",
364
+ "echo 'EXCLEND'"
365
+ );
366
+ }
367
+ return lines.join("\n");
368
+ }
369
+ function dirExistsScript(dir) {
370
+ return "[ -d " + psq(dir) + " ] && echo YES || echo NO";
371
+ }
372
+ function countObjectsScript(store, gitExe) {
373
+ return [
374
+ "git=" + psq(gitExe),
375
+ "g=" + psq(store.git),
376
+ '"$git" --git-dir="$g" count-objects -v'
377
+ ].join("\n");
378
+ }
379
+ function diskUsageScript(dir) {
380
+ return "du -sk " + psq(dir) + " 2>/dev/null | awk '{print $1 * 1024}'";
381
+ }
382
+ function listSubdirsScript(dir) {
383
+ return "find " + psq(dir) + " -maxdepth 1 -mindepth 1 -type d 2>/dev/null";
384
+ }
385
+ function storesDumpScript(container, extraDirs) {
386
+ const lines = ["set -e", "dirs=()"];
387
+ if (container) {
388
+ lines.push("base=" + psq(container));
389
+ lines.push('if [ -d "$base" ]; then');
390
+ lines.push(' for d in "$base"/*/; do');
391
+ lines.push(' if [ -d "$d" ]; then dirs+=("${d%/}"); fi');
392
+ lines.push(" done");
393
+ lines.push("fi");
394
+ }
395
+ for (const d of extraDirs || []) lines.push("dirs+=(" + psq(d) + ")");
396
+ lines.push(
397
+ 'for d in "${dirs[@]}"; do',
398
+ ' [ -d "$d" ] || continue',
399
+ ' echo "==DIR $d"',
400
+ ' if [ -f "$d/root.txt" ]; then',
401
+ ` printf "ROOT %s\\n" "$(cat "$d/root.txt" 2>/dev/null | tr -d '\\r\\n')"`,
402
+ " else",
403
+ ' echo "ROOT "',
404
+ " fi",
405
+ " echo INDEXBEGIN",
406
+ ' cat "$d/index.json" 2>/dev/null',
407
+ " echo INDEXEND",
408
+ " echo LINEAGEBEGIN",
409
+ ' cat "$d/lineage.json" 2>/dev/null',
410
+ " echo LINEAGEEND",
411
+ "done",
412
+ "exit 0"
413
+ );
414
+ return lines.join("\n");
415
+ }
416
+ export {
417
+ FIDELITY_ATTRS,
418
+ HEARTBEAT_TTL_S,
419
+ MAX_FILE_BYTES,
420
+ STALE_LOCK_MIN,
421
+ UTF8_PRELUDE,
422
+ countObjectsScript,
423
+ diffScript,
424
+ dirExistsScript,
425
+ diskUsageScript,
426
+ ensureGitScript,
427
+ excludeDumpScript,
428
+ excludeReadCmd,
429
+ fileWriteStdinCmd,
430
+ gcScript,
431
+ indexReadCmd,
432
+ killOrphansScript,
433
+ legacyHomeMigrateScript,
434
+ legacyRmScript,
435
+ lineageReadCmd,
436
+ listSubdirsScript,
437
+ listTagsScript,
438
+ listTagsWithTimeScript,
439
+ migrateScript,
440
+ mkdirScript,
441
+ probeHomeScript,
442
+ pruneScript,
443
+ psq,
444
+ purgeTagsScript,
445
+ renameFileCmd,
446
+ rescueScript,
447
+ resolveGitScript,
448
+ rollbackScript,
449
+ snapshotScript,
450
+ storesDumpScript,
451
+ stripBom
452
+ };