dsh-recall-plugin 1.0.4 → 1.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.
- package/README.md +25 -7
- package/lib/client.js +1 -1
- package/lib/index.js +42 -603
- package/lib/maintenance.js +107 -0
- package/lib/scripts.posix.js +290 -0
- package/lib/scripts.pwsh.js +334 -0
- package/lib/snapshots.js +192 -0
- package/lib/store.js +249 -0
- package/package.json +2 -3
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 快照维护(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
+
*
|
|
4
|
+
* 职责:磁盘占用治理,两件事——
|
|
5
|
+
* 1. 定期 git gc:全量保留策略下把 loose 对象压 pack + 跨版本 delta,
|
|
6
|
+
* 无损(所有 tag 可达对象一个不丢),通常省一半以上空间;
|
|
7
|
+
* 2. 会话删除联动清理:会话日志已从磁盘消失时,删除该会话全部快照 tag
|
|
8
|
+
* 并重写索引,空间由紧随的同一次 gc --prune=now 真正释放。
|
|
9
|
+
*
|
|
10
|
+
* 触发点在每条用户消息快照之后的同一条串行队列里(见 index.js 的事件
|
|
11
|
+
* 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// gc 节流阈值:每 GC_SNAPS 条快照或距上次 gc GC_MS 毫秒,先到先触发。
|
|
15
|
+
// 默认「50 条或 24 小时」——重活(gc)一天至多一次的量级,轻会话用户
|
|
16
|
+
// 也不会等太久。支持环境变量覆盖,供高级用户与冒烟测试调档。
|
|
17
|
+
const GC_SNAPS = Math.max(1, parseInt(process.env.DSH_RECALL_GC_SNAPS || '', 10) || 50)
|
|
18
|
+
const GC_MS = Math.max(1, parseInt(process.env.DSH_RECALL_GC_HOURS || '', 10) || 24) * 3600000
|
|
19
|
+
|
|
20
|
+
export function createMaintenance(ctx, rt, snaps) {
|
|
21
|
+
const sessions = ctx.sessions
|
|
22
|
+
const state = rt.state
|
|
23
|
+
// 平台选择的脚本模板(gc/purge 两套模板同名导出)
|
|
24
|
+
const S = rt.scripts
|
|
25
|
+
|
|
26
|
+
// 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
|
|
27
|
+
// tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
|
|
28
|
+
// best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
|
|
29
|
+
async function purgeSession(sessionId) {
|
|
30
|
+
const byRoot = new Map()
|
|
31
|
+
for (const [id, s] of state.snapshots.entries()) {
|
|
32
|
+
if (!s || s.sessionId !== sessionId) continue
|
|
33
|
+
if (!byRoot.has(s.root)) byRoot.set(s.root, [])
|
|
34
|
+
byRoot.get(s.root).push(id)
|
|
35
|
+
}
|
|
36
|
+
let purged = 0
|
|
37
|
+
for (const [root, ids] of byRoot) {
|
|
38
|
+
const store = state.stores.get(root)
|
|
39
|
+
if (!store || !state.gitExe) continue
|
|
40
|
+
try {
|
|
41
|
+
for (let i = 0; i < ids.length; i += 100) {
|
|
42
|
+
await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
43
|
+
}
|
|
44
|
+
for (const id of ids) state.snapshots.delete(id)
|
|
45
|
+
await snaps.saveIndex(root, sessionId)
|
|
46
|
+
purged += ids.length
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('recall purge session failed:', String(error))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
|
|
52
|
+
return purged
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 扫描索引里出现过的全部会话:既不在 sessions 注册表、冷读日志又失败的,
|
|
56
|
+
// 才认定「已删除」。两个保守闸门:
|
|
57
|
+
// - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
|
|
58
|
+
// 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
|
|
59
|
+
// - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
|
|
60
|
+
// 不会被误清——只有日志真正消失才触发。
|
|
61
|
+
async function sweepDeletedSessions() {
|
|
62
|
+
const ids = new Set()
|
|
63
|
+
for (const s of state.snapshots.values()) {
|
|
64
|
+
if (s && s.sessionId) ids.add(s.sessionId)
|
|
65
|
+
}
|
|
66
|
+
if (!ids.size) return
|
|
67
|
+
const query = ctx.get('sessionQuery')
|
|
68
|
+
if (!query || typeof query.readSession !== 'function') return
|
|
69
|
+
for (const id of ids) {
|
|
70
|
+
if (sessions.get(id)) continue
|
|
71
|
+
let alive = false
|
|
72
|
+
try {
|
|
73
|
+
const log = await query.readSession(id)
|
|
74
|
+
alive = Boolean(log)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
alive = false
|
|
77
|
+
}
|
|
78
|
+
if (!alive) await purgeSession(id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 维护入口(每条消息快照后串行调用):先清理后 gc——被删 tag 腾出的
|
|
83
|
+
// 对象靠同一次 gc --prune=now 释放,一次干两件事。
|
|
84
|
+
// 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
|
|
85
|
+
// 会让后续每条消息都重试一次重量级 gc,把队列堵住。
|
|
86
|
+
async function maybeMaintain(sessionId) {
|
|
87
|
+
const root = await rt.resolveRoot(sessionId)
|
|
88
|
+
if (!root) return
|
|
89
|
+
const store = state.stores.get(root)
|
|
90
|
+
if (!store || !state.gitExe) return
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
const last = state.gcLastAt.get(store.git) || 0
|
|
93
|
+
const count = (state.gcCount.get(store.git) || 0) + 1
|
|
94
|
+
state.gcCount.set(store.git, count)
|
|
95
|
+
if (count < GC_SNAPS && now - last < GC_MS) return
|
|
96
|
+
state.gcCount.set(store.git, 0)
|
|
97
|
+
try {
|
|
98
|
+
await sweepDeletedSessions()
|
|
99
|
+
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('recall maintenance failed:', String(error))
|
|
102
|
+
}
|
|
103
|
+
state.gcLastAt.set(store.git, Date.now())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { maybeMaintain, sweepDeletedSessions, purgeSession }
|
|
107
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
// 与 TraeWork 同级的超大文件跳过阈值(字节),与 pwsh 版一致
|
|
30
|
+
export const MAX_FILE_BYTES = 104857600
|
|
31
|
+
|
|
32
|
+
// bash/cat 输出无 BOM;保留同名导出维持两套模板接口一致(幂等无害)
|
|
33
|
+
export function stripBom(text) {
|
|
34
|
+
return text.replace(/^\uFEFF/, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 嵌套 git 仓库(工作区里的子项目自带 .git)会被 add -A 记成 gitlink
|
|
38
|
+
// (160000);gitlink 残留在 index 时 add -A 会 fatal,且对文件回退毫无
|
|
39
|
+
// 意义——所以 add 前后各清一次,子仓库内容不进快照。
|
|
40
|
+
// 依赖外层脚本已定义的 $git/$g;被 snapshot/diff/rollback 三处复用。
|
|
41
|
+
function dropGitlinksBlock() {
|
|
42
|
+
return [
|
|
43
|
+
'"$git" --git-dir="$g" ls-files -z --stage | while IFS= read -r -d \'\' e; do',
|
|
44
|
+
' case "$e" in',
|
|
45
|
+
" 160000\\ *) p=${e#*$'\\t'}; \"$git\" --literal-pathspecs --git-dir=\"$g\" update-index --force-remove -- \"$p\" ;;",
|
|
46
|
+
' esac',
|
|
47
|
+
'done'
|
|
48
|
+
].join('\n')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 剔除超大文件:find -print0 + read -d '' 按字节安全遍历(文件名含换行
|
|
52
|
+
// 也不怕);2>/dev/null 容忍个别不可访问子目录(杀软锁定、异常 ACL),
|
|
53
|
+
// 漏看个别文件是 fail-open,可接受——与 pwsh 版同策略。
|
|
54
|
+
// 依赖外层已定义的 $git/$g/$root。
|
|
55
|
+
function oversizeBlock() {
|
|
56
|
+
return [
|
|
57
|
+
'find "$root" -type f -size +' + MAX_FILE_BYTES + 'c -print0 2>/dev/null | while IFS= read -r -d \'\' f; do',
|
|
58
|
+
' rel=${f#"$root"/}',
|
|
59
|
+
' "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- "$rel"',
|
|
60
|
+
'done'
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 用户自定义排除同步:基础排除表 + 用户 exclude.txt 合并重写 info/exclude,
|
|
65
|
+
// 再用 ls-files -i -c --exclude-from 找出「已被跟踪但命中排除」的条目清掉。
|
|
66
|
+
// 与 pwsh 版同语义:只用 --exclude-from,不引入项目 .gitignore(--exclude-standard)
|
|
67
|
+
// 的语义;放在 add -A 之前让排除先生效。read 循环里做 trim + 注释过滤,
|
|
68
|
+
// 兼容 Windows 上编辑带 CRLF 的 exclude.txt。
|
|
69
|
+
// 依赖外层已定义的 $git/$g。
|
|
70
|
+
function excludeSyncBlock(excludeFile) {
|
|
71
|
+
return [
|
|
72
|
+
'ex_file=' + psq(excludeFile),
|
|
73
|
+
'exc="$g/info/exclude"',
|
|
74
|
+
'user_pats=""',
|
|
75
|
+
'if [ -f "$ex_file" ]; then',
|
|
76
|
+
' while IFS= read -r line || [ -n "$line" ]; do',
|
|
77
|
+
" t=${line%$'\\r'}",
|
|
78
|
+
' t="${t#"${t%%[![:space:]]*}"}"; t="${t%"${t##*[![:space:]]}"}"',
|
|
79
|
+
' [ -z "$t" ] && continue',
|
|
80
|
+
' case "$t" in \\#*) continue ;; esac',
|
|
81
|
+
' user_pats="$user_pats$t\\n"',
|
|
82
|
+
' done < "$ex_file"',
|
|
83
|
+
'fi',
|
|
84
|
+
"printf '\\n.git\\nnode_modules/\\n.dsh-recall-snapshots/\\n%b' \"$user_pats\" > \"$exc\"",
|
|
85
|
+
'"$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',
|
|
86
|
+
' [ -n "$p" ] && "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- "$p"',
|
|
87
|
+
'done || true'
|
|
88
|
+
].join('\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 解析 git 可执行文件路径:bash 从 PATH 找(POSIX 上 git 装了就在 PATH,
|
|
92
|
+
// 没有 Windows 那种四类安装位置的散装问题)
|
|
93
|
+
export function resolveGitScript() {
|
|
94
|
+
return [
|
|
95
|
+
'p=$(command -v git 2>/dev/null || true)',
|
|
96
|
+
'[ -n "$p" ] && printf \'%s\\n\' "$p"',
|
|
97
|
+
'exit 0'
|
|
98
|
+
].join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 探测 bash 侧的 home 基底:只回显 bash env 里的 $DSH_HOME(可能为空)。
|
|
102
|
+
// 为什么不在这里回退 $HOME:DSH 的 bash 执行器会洗刷子进程的 DSH_* 变量
|
|
103
|
+
// (dsh-subprocess scrubbedParentEnv),用户导出的 DSH_HOME 在 bash 里
|
|
104
|
+
// 通常不可见——若在此回退 $HOME,Node 侧的字面量回退永远轮不到,
|
|
105
|
+
// 「DSH_HOME 指到哪、快照就存哪」会失效。优先级与 pwsh 版对齐:
|
|
106
|
+
// bash env 显式值 > Node 主进程 DSH_HOME > $HOME(os.homedir)。
|
|
107
|
+
export function probeHomeScript() {
|
|
108
|
+
return 'printf \'%s\' "${DSH_HOME:-}"'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function mkdirScript(dir) {
|
|
112
|
+
return 'mkdir -p -- ' + psq(dir)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 旧版迁移:把降级时代落在项目内的影子仓库整体搬回 home 并删源目录
|
|
116
|
+
export function migrateScript(src, dst) {
|
|
117
|
+
return [
|
|
118
|
+
'set -e',
|
|
119
|
+
'src=' + psq(src),
|
|
120
|
+
'dst=' + psq(dst),
|
|
121
|
+
'if [ -e "$src/git" ]; then mv -f "$src/git" "$dst/git"; fi',
|
|
122
|
+
'if [ -e "$src/index.json" ]; then mv -f "$src/index.json" "$dst/index.json"; fi',
|
|
123
|
+
'rm -rf -- "$src"',
|
|
124
|
+
'echo MIGRATE_OK'
|
|
125
|
+
].join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 建立影子仓库 + 排除同步 + 回读 gc.stamp(语义与 pwsh 版一致,
|
|
129
|
+
// 见 scripts.pwsh.js 同名函数注释)
|
|
130
|
+
export function ensureGitScript(store, gitExe) {
|
|
131
|
+
return [
|
|
132
|
+
'set -e',
|
|
133
|
+
'git=' + psq(gitExe),
|
|
134
|
+
'repo=' + psq(store.repo),
|
|
135
|
+
'g=' + psq(store.git),
|
|
136
|
+
'[ -d "$g" ] || "$git" init "$repo" >/dev/null',
|
|
137
|
+
'"$git" --git-dir="$g" config core.longpaths true',
|
|
138
|
+
'"$git" --git-dir="$g" config core.autocrlf false',
|
|
139
|
+
'"$git" --git-dir="$g" config advice.addEmbeddedRepo false',
|
|
140
|
+
excludeSyncBlock(store.excludeFile),
|
|
141
|
+
'stamp="$g/gc.stamp"',
|
|
142
|
+
'if [ -f "$stamp" ]; then printf \'GIT_OK %s\\n\' "$(head -n1 "$stamp" 2>/dev/null)"; else echo GIT_OK; fi'
|
|
143
|
+
].join('\n')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 快照:add -A → write-tree → commit-tree(孤儿提交)→ tag(语义同 pwsh 版)
|
|
147
|
+
export function snapshotScript(root, store, gitExe, messageId) {
|
|
148
|
+
return [
|
|
149
|
+
'set -e',
|
|
150
|
+
'git=' + psq(gitExe),
|
|
151
|
+
'g=' + psq(store.git),
|
|
152
|
+
'root=' + psq(root),
|
|
153
|
+
dropGitlinksBlock(),
|
|
154
|
+
excludeSyncBlock(store.excludeFile),
|
|
155
|
+
'"$git" --git-dir="$g" --work-tree="$root" add -A',
|
|
156
|
+
dropGitlinksBlock(),
|
|
157
|
+
oversizeBlock(),
|
|
158
|
+
'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
|
|
159
|
+
'commit=$("$git" --git-dir="$g" -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree "$tree" -m ' + psq('snapshot ' + messageId) + ')',
|
|
160
|
+
'"$git" --git-dir="$g" tag ' + psq('snap-' + messageId) + ' "$commit" >/dev/null',
|
|
161
|
+
'echo SNAP_OK'
|
|
162
|
+
].join('\n')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 当前清单/目标树清单落临时文件:两处复用(diff 与 rollback)。
|
|
166
|
+
// 关键前置链与 pwsh 版对齐——gitlink 清理 → 排除同步 → add -A → 再清
|
|
167
|
+
// gitlink → 超大剔除——少了 add -A 的话 ls-files 读到的还是上一次快照的
|
|
168
|
+
// 旧 index,「当前清单」永远等于目标 tag,diff 恒空(调试踩过的坑)。
|
|
169
|
+
// 行格式:cur 为「mode sha stage<TAB>path」(取 sha=a[2]),target 为
|
|
170
|
+
// 「mode type sha<TAB>path」(取 sha=a[3]);grep 滤掉 gitlink(160000)行,
|
|
171
|
+
// 无匹配时退出码 1,set -e 下统一 || true。
|
|
172
|
+
function collectListsBlock(store, gitExe, root, tag, curVar, tgtVar) {
|
|
173
|
+
return [
|
|
174
|
+
'git=' + psq(gitExe),
|
|
175
|
+
'g=' + psq(store.git),
|
|
176
|
+
'root=' + psq(root),
|
|
177
|
+
dropGitlinksBlock(),
|
|
178
|
+
excludeSyncBlock(store.excludeFile),
|
|
179
|
+
'"$git" --git-dir="$g" --work-tree="$root" add -A',
|
|
180
|
+
dropGitlinksBlock(),
|
|
181
|
+
oversizeBlock(),
|
|
182
|
+
'tmpc=' + psq(store.dir + '/diff-cur.$$'),
|
|
183
|
+
'tmpt=' + psq(store.dir + '/diff-tgt.$$'),
|
|
184
|
+
'"$git" -c core.quotePath=false --git-dir="$g" --work-tree="$root" ls-files --stage | grep -v \'^160000 \' > "$tmpc" || true',
|
|
185
|
+
'"$git" -c core.quotePath=false --git-dir="$g" ls-tree -r ' + psq(tag) + ' | grep -v \'^160000 \' > "$tmpt" || true'
|
|
186
|
+
].join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// diff:awk 一趟对比 cur/target(cur 侧 "mode sha<TAB>path" 取 a[2],
|
|
190
|
+
// target 侧 "mode type sha<TAB>path" 取 a[3]),输出 TSV「kind<TAB>path」
|
|
191
|
+
// 逐行打印,Node 侧解析(不在 bash 里拼 JSON——没有 jq 依赖、
|
|
192
|
+
// 转义路径的坑也一并消失)。sort -k2 按 path 确定序,与 pwsh 版对齐。
|
|
193
|
+
export function diffScript(root, store, gitExe, tag) {
|
|
194
|
+
return [
|
|
195
|
+
'set -e -o pipefail',
|
|
196
|
+
collectListsBlock(store, gitExe, root, tag),
|
|
197
|
+
"trap 'rm -f \"$tmpc\" \"$tmpt\"' EXIT",
|
|
198
|
+
'awk -F\'\\t\' -v OFS=\'\\t\' \'',
|
|
199
|
+
' FNR==1 { fidx++ }',
|
|
200
|
+
' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
|
|
201
|
+
' { split($1, a, " "); tgt[$2]=a[3] }',
|
|
202
|
+
' END {',
|
|
203
|
+
' for (p in cur) {',
|
|
204
|
+
' if (p in tgt) { if (tgt[p] != cur[p]) print "modified", p }',
|
|
205
|
+
' else print "added", p',
|
|
206
|
+
' }',
|
|
207
|
+
' for (p in tgt) if (!(p in cur)) print "restored", p',
|
|
208
|
+
' }',
|
|
209
|
+
"' \"$tmpc\" \"$tmpt\" | sort -t$'\\t' -k2,2",
|
|
210
|
+
'exit 0'
|
|
211
|
+
].join('\n')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 回退:archive | tar 直接管到工作区(无需 Windows 的 zip 中转),
|
|
215
|
+
// 空目标跳过;再删除「当前有、目标无」的文件(awk 求差集)。
|
|
216
|
+
// pipefail 保证 git archive 失败时整条非零退出。
|
|
217
|
+
export function rollbackScript(root, store, gitExe, tag) {
|
|
218
|
+
return [
|
|
219
|
+
'set -e -o pipefail',
|
|
220
|
+
collectListsBlock(store, gitExe, root, tag),
|
|
221
|
+
"trap 'rm -f \"$tmpc\" \"$tmpt\"' EXIT",
|
|
222
|
+
'restored=$(wc -l < "$tmpt" | tr -d \' \')',
|
|
223
|
+
'if [ "$restored" -gt 0 ]; then',
|
|
224
|
+
// -m(--touch):解包不恢复归档成员的 mtime(文件 mtime = 解包时刻)。
|
|
225
|
+
// 必须如此:tar 默认保留归档内 mtime,而快照→篡改→回滚常在数秒内
|
|
226
|
+
// 完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录碰撞,下一次
|
|
227
|
+
// add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区内容与快照
|
|
228
|
+
// 从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
|
|
229
|
+
// Expand-Archive 天然把 mtime 设为解包时刻,无此问题;-m 让 tar 对齐。
|
|
230
|
+
' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
|
|
231
|
+
'fi',
|
|
232
|
+
'tmpd=' + psq(store.dir + '/diff-del.$$'),
|
|
233
|
+
"trap 'rm -f \"$tmpc\" \"$tmpt\" \"$tmpd\"' EXIT",
|
|
234
|
+
'awk -F\'\\t\' \'',
|
|
235
|
+
' FNR==1 { fidx++ }',
|
|
236
|
+
' fidx==1 { cur[$2]=1; next }',
|
|
237
|
+
' { tgt[$2]=1 }',
|
|
238
|
+
' END { for (p in cur) if (!(p in tgt)) print p }',
|
|
239
|
+
"' \"$tmpc\" \"$tmpt\" > \"$tmpd\"",
|
|
240
|
+
'deleted=0',
|
|
241
|
+
'while IFS= read -r p; do',
|
|
242
|
+
' [ -z "$p" ] && continue',
|
|
243
|
+
' rm -f -- "$root/$p" && deleted=$((deleted + 1))',
|
|
244
|
+
'done < "$tmpd"',
|
|
245
|
+
'echo "ROLLBACK_OK $deleted $restored"'
|
|
246
|
+
].join('\n')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function listTagsScript(store, gitExe) {
|
|
250
|
+
return [
|
|
251
|
+
'set -e',
|
|
252
|
+
'git=' + psq(gitExe),
|
|
253
|
+
'g=' + psq(store.git),
|
|
254
|
+
'"$git" --git-dir="$g" tag -l \'snap-*\''
|
|
255
|
+
].join('\n')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 定期 gc(语义同 pwsh 版);date +%s 写秒级时间戳,JS 侧 ×1000
|
|
259
|
+
export function gcScript(store, gitExe) {
|
|
260
|
+
return [
|
|
261
|
+
'set -e',
|
|
262
|
+
'git=' + psq(gitExe),
|
|
263
|
+
'g=' + psq(store.git),
|
|
264
|
+
'"$git" --git-dir="$g" gc --quiet --prune=now',
|
|
265
|
+
'date +%s > "$g/gc.stamp"',
|
|
266
|
+
'echo GC_OK'
|
|
267
|
+
].join('\n')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 删除指定快照 tag(会话已删联动清理用):tag -d 对不存在 tag 非零退出,
|
|
271
|
+
// || true 吞掉——best-effort,残留 tag 由下次清理幂等收尾(同 pwsh 版)
|
|
272
|
+
export function purgeTagsScript(store, gitExe, tags) {
|
|
273
|
+
return [
|
|
274
|
+
'git=' + psq(gitExe),
|
|
275
|
+
'g=' + psq(store.git),
|
|
276
|
+
'"$git" --git-dir="$g" tag -d ' + tags.map((t) => psq(t)).join(' ') + ' >/dev/null 2>&1 || true',
|
|
277
|
+
'echo PURGE_DONE'
|
|
278
|
+
].join('\n')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 索引读取(写入走 stdin:见 snapshots.js saveIndex 的 POSIX 分支,
|
|
282
|
+
// 不经命令行传参,天然没有 32767/128KB argv 上限问题)
|
|
283
|
+
export function indexReadCmd(dir) {
|
|
284
|
+
return 'cat ' + psq(dir + '/index.json') + ' 2>/dev/null || true'
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 旧版项目内 blobs 目录清理(仅 home 存储可用时调用)
|
|
288
|
+
export function legacyRmScript(path) {
|
|
289
|
+
return 'rm -rf -- ' + psq(path)
|
|
290
|
+
}
|