dsh-recall-plugin 2.6.1 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.en.md +5 -1
- package/README.md +5 -1
- package/lib/client.js +10 -4
- package/lib/diagnostics.js +8 -1
- package/lib/routes-core.js +1 -1
- package/lib/scripts.posix.js +3 -3
- package/lib/scripts.pwsh.js +75 -13
- package/lib/snapshots.js +27 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
+
## [2.6.3] - 2026-11-03
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **Windows 回退解压不再逐条走 `Expand-Archive`(issue #34)**:整树与分批两个恢复分支改走共用的逐条 .NET `ExtractToFile(overwrite: true)`,目录创建按大小写不敏感集合缓存,显式 UTF-8 并 `finally` 释放归档句柄。保留覆盖、CJK 路径、恢复计数、archive 失败可见与 H1 救援语义;issue 报告者的 5000 文件实测为 17.2s → 4.0s,本机冷缓存受 Defender 影响较大,不承诺固定耗时。
|
|
10
|
+
- **Windows 回退支持超过 260 字符的路径**:恢复与删除统一用 `\\?\`(UNC 为 `\\?\UNC\`)扩展绝对路径拼接,解压经 .NET 目录/文件 API 落盘;删除改用 `File.Delete` 并先清只读属性,普通路径与超长路径行为一致。解压失败或目录替换仍会终止并进入 H1 救援,不会假报 `ROLLBACK_OK`。
|
|
11
|
+
- **回退恢复后的文件 mtime 显式刷新**:逐条解压保留的是快照提交时间,与旧 index 的 stat 记录可能碰撞,导致紧接着的快照漏掉恢复内容、后续回退取回错误版本。现在与 POSIX 的 `tar -m` 对齐,把恢复文件 touch 到当前时刻。
|
|
12
|
+
- **大变更预览的分类计数改为全量口径**:此前摘要按截断后的前 500 条清单统计,与总数并列时读起来像清单丢了条目(实测 3054 条变更只报「修改 448 · 恢复 50 · 删除 2」)。现在 Windows 脚本在截断前输出 `COUNTS` 行、POSIX 由 Host 按全量清单统计,确认面板用全量计数渲染摘要;拿不到可信计数时面板隐去摘要,而不是显示一个对不上的数字。
|
|
13
|
+
- **工作区路径过长时给出可行动提示**:新增 `pathTooLong` 环境错误分类,识别 git 的 `Filename too long` 与 PowerShell 拉起 native 命令失败的 `The directory name is invalid`,提示「把项目移到更短的路径」。这是 Windows `MAX_PATH` 的平台限制(工作区根 ≥260 字符时 git 拒绝索引,`\\?\` 只救工作区内部的深文件),已在 README「已知限制」写明。
|
|
14
|
+
|
|
15
|
+
### 测试
|
|
16
|
+
|
|
17
|
+
- **POSIX 回退补齐真执行回归**:新增 bash + git 冒烟,覆盖分批恢复、CJK/空格路径、`tar -m` 的 mtime 失配和超过 260 字符的路径。POSIX 模板原本已具备这些语义,本次只修正过时注释,不改实现。
|
|
18
|
+
- **长路径用例改为按实际根长度构造**:原先写死目录段数,在 CI 的 `/tmp` 短根上目标路径刚好 260 字符、断言落空(本地 `%TEMP%` 更长才通过)。现在按根长度反推段数,跨 runner 稳定。
|
|
19
|
+
|
|
20
|
+
## [2.6.2] - 2026-11-03
|
|
21
|
+
|
|
22
|
+
### 修复
|
|
23
|
+
|
|
24
|
+
- **Windows 保留设备名不再反复触发快照跳过提示(issue #32)**:`nul`、`aux`、`con`、`prn`、`com1`…、`lpt1`… 及其扩展名和上标数字变体在 `git add` 前默认排除,覆盖任意子目录;规则仅适用于 Windows,不进入构建产物 root 判定,POSIX 合法同名文件不受影响。这些路径保持「不恢复、不删除」的既有回退语义。
|
|
25
|
+
- **0 字节 `nul` 等路径的失败提示不再静默丢失**:`SNAP_SKIP` 同时识别 Git 的 `unable to index file 'X'` 与 `unable to add 'X' to index`,PowerShell 与 bash 模板均已覆盖。
|
|
26
|
+
|
|
5
27
|
## [2.6.1] - 2026-10-31
|
|
6
28
|
|
|
7
29
|
### 新增
|
package/README.en.md
CHANGED
|
@@ -77,6 +77,8 @@ Boundaries accepted by design and edge cases not yet covered — worth checking
|
|
|
77
77
|
- Recall cannot be initiated while an agent is running in the target workspace (by design — stop the agent first).
|
|
78
78
|
- 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.
|
|
79
79
|
- 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.
|
|
80
|
+
- Windows reserved device names (such as `nul`, `aux`, `con`, and `com1`, including extension forms) cannot be indexed by Git and are excluded from snapshots and recalls by default. These paths are left untouched and do not generate repeated skip notices. This exclusion applies only to Windows.
|
|
81
|
+
- A **workspace whose root path itself exceeds 260 characters** is not supported: that is Windows' `MAX_PATH` hard limit — git refuses to index such a directory (`\\?\` extended paths only rescue deep files *inside* the workspace, not the root prefix itself; measured failure starting at 259 characters). Snapshots for such a workspace fail, and "Recent errors" tells you to move the project to a shorter path. Deep files inside a workspace (short root, file path over 260) are unaffected and snapshot / roll back normally.
|
|
80
82
|
- **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).
|
|
81
83
|
- Extreme cases like filenames containing newlines/TAB are beyond the diff list's parsing capability (negligible probability).
|
|
82
84
|
- **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).
|
|
@@ -201,7 +203,7 @@ The most common questions and their troubleshooting paths; for the full set of d
|
|
|
201
203
|
|
|
202
204
|
**No "↶ Recall" button next to a message?** Check in order of likelihood: (1) the DSH process wasn't restarted after installing — restart it and hard-refresh (Ctrl+Shift+R); (2) git CLI isn't on PATH — a notice shows at the top of the page; (3) the message predates the plugin — snapshots are only created when a message is sent; (4) snapshots are disabled for that workspace — it sits under a directory-form exclusion (e.g. opened inside `dist/`), exceeded the `maxWorkspaceBytes` / `scanBudgetMs` guardrails, or the pre-snapshot size scan itself failed (`git ls-files` error; the reason is in "Recent errors" and it retries after 10 minutes) — recent messages carry a recovery notice (raise the limit or extend the exclusion list and it recovers automatically).
|
|
203
205
|
|
|
204
|
-
**Some files weren't restored by a recall?** They weren't in the snapshot: larger than `maxFileBytes` (8MB by default), matched by the exclusion list, or inside a nested git repository / a win32 reparse point (see "Known Limitations"). The confirmation panel's file list before a recall is the exact rollback scope — trust it.
|
|
206
|
+
**Some files weren't restored by a recall?** They weren't in the snapshot: larger than `maxFileBytes` (8MB by default), matched by the exclusion list (including Windows reserved device names), or inside a nested git repository / a win32 reparse point (see "Known Limitations"). The confirmation panel's file list before a recall is the exact rollback scope — trust it.
|
|
205
207
|
|
|
206
208
|
**Snapshots eating too much disk?** Open "Settings → Plugin Config → Recall Plugin → Snapshot Manager" to see per-workspace usage, run "gc now", or delete by workspace / session / single snapshot; longer-term, lower `maxSnapshotsPerWorkspace`, set `retentionDays`, or add big directories to the exclusion list.
|
|
207
209
|
|
|
@@ -209,6 +211,8 @@ The most common questions and their troubleshooting paths; for the full set of d
|
|
|
209
211
|
|
|
210
212
|
**Can the session's first message be recalled or edited?** Files yes, conversation no — fork needs an earlier turn boundary; "Edit and resend" on the first message is refused outright (the original message is still there, so resending would duplicate it).
|
|
211
213
|
|
|
214
|
+
**A recall added a new session to the list — can that be avoided?** No, this is a platform boundary rather than a setting: the only channel DSH offers for "go back to before a message" is `sessions.fork`, and a fork necessarily creates a new session — the client session surface has only fork / open / list / binding, with no verb to delete or truncate a session in place. What the plugin controls is what happens to the **original** session (`archiveOriginal`, on by default: archived to "Archive", recoverable), not whether a new one exists. If by "branch" you meant a git branch: the plugin never creates one — snapshots are `snap-<message ID>` tags only, and your project's own git state is never touched.
|
|
215
|
+
|
|
212
216
|
## How It Works
|
|
213
217
|
|
|
214
218
|
When each user message is sent (before the agent touches any files), the workspace is snapshotted into an independent shadow git repository; on recall, a "pre-rollback" safety snapshot is taken first, then files are restored via `git archive` and the conversation is rewound through DSH's official `sessions.fork` mechanism. Binary- and line-ending-safe, and your project's own git state is never touched.
|
package/README.md
CHANGED
|
@@ -74,6 +74,8 @@
|
|
|
74
74
|
- 目标工作区的 agent 正在运行时无法发起撤回(防护设计,先停止 agent 再撤回)。
|
|
75
75
|
- 支持 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 兼容编写,尚未真机实测。
|
|
76
76
|
- 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)无法索引:其余部分快照照常(fail-open,页面会提示跳过了哪些路径),但其内容不参与回退。
|
|
77
|
+
- Windows 保留设备名(如 `nul`、`aux`、`con`、`com1`,含扩展名形式)无法由 Git 索引,默认不参与快照与回退;这些路径保持原样,也不会反复产生跳过提示。此排除仅适用于 Windows。
|
|
78
|
+
- **工作区根路径自身超过 260 字符**时不支持撤回:这是 Windows `MAX_PATH` 的硬限制——git 拒绝索引这类目录(`\\?\` 扩展路径只能救工作区**内部**的深文件,救不了根前缀本身;实测根长 259 字符起就失败)。这种工作区的快照会失败,「最近错误」里提示把项目移到更短的路径。工作区内的深文件(根短、文件路径超 260)不受影响,照常快照与回退。
|
|
77
79
|
- 工作区内的**目录重解析点**(junction / 目录符号链接)默认不参与快照:它们指向的子树不会被索引,因此那部分内容不参与回退。win32 上这是必需的——git for Windows 把 junction 当普通目录递归,自引用 junction 会让索引膨胀到 Windows 的 31 层重解析点上限(实测 2 个文件的工作区产出 64 条索引);POSIX 侧符号链接照常进快照(git 记成 `120000` 条目,不递归进目标)。确需把某个**良性** junction(指向工作区外、非自引用)纳入快照时,可在排除配置里追加 `!路径/`(如 `!ext-link/`)反选恢复——用户排除行在机器生成行之后生效;**切勿对自引用 junction 使用**(爆炸根因)。
|
|
78
80
|
- 文件名含换行/TAB 的极端情形超出 diff 清单的解析能力(概率可忽略)。
|
|
79
81
|
- **与 dsh-routing-suite(渐进式工具披露路由)的交互**:同时启用其 router-standard 预设时,撤回经 `sessions.fork` 出新会话会把路由阶段重置为默认(工具面临时收窄)。现象、成因与解决方案见 [docs/routing-interplay.md](docs/routing-interplay.md)。
|
|
@@ -199,7 +201,7 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
|
|
|
199
201
|
|
|
200
202
|
**消息旁没有「↶ 撤回」按钮?** 按概率排查:① 装完没重启 DSH 进程——重启后硬刷新页面(Ctrl+Shift+R);② git CLI 不在 PATH——页面顶部会有安装提示;③ 该消息发送于插件启用之前——快照只在消息发送时创建,历史消息无快照;④ 该工作区被停用快照——工作区开在 `dist/`、`target/` 这类命中基础排除表目录形态的位置,或超出 `maxWorkspaceBytes` / `scanBudgetMs` 护栏,或快照前的范围统计本身失败(`git ls-files` 报错,原因见「最近错误」,10 分钟后自动重试);最近消息上会给出恢复提示(调大上限或补充排除表后自动恢复)。
|
|
201
203
|
|
|
202
|
-
**撤回后个别文件没有恢复?** 该文件不在快照里:超过 `maxFileBytes`(默认 8MB
|
|
204
|
+
**撤回后个别文件没有恢复?** 该文件不在快照里:超过 `maxFileBytes`(默认 8MB)、命中排除表(含 Windows 保留设备名),或位于嵌套 git 仓库 / win32 目录重解析点内(见「已知限制」)。撤回前确认面板的清单就是实际回退范围,以它为准。
|
|
203
205
|
|
|
204
206
|
**快照占用磁盘太多?** 打开「设置 → 插件配置 → 撤回插件 → 快照管理」查看各工作区占用,可「立即 gc」或按工作区 / 会话 / 单条分级删除;长期可压低 `maxSnapshotsPerWorkspace`、设置 `retentionDays`,或把大目录补进排除表。
|
|
205
207
|
|
|
@@ -207,6 +209,8 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
|
|
|
207
209
|
|
|
208
210
|
**会话第一条消息能撤回或编辑吗?** 文件可以回退,对话不能——fork 需要该消息之前的 turn 边界;「编辑并重发」对首条消息会被明确拒绝(原消息还在,补发即成重复消息)。
|
|
209
211
|
|
|
212
|
+
**撤回后会话列表多出一个会话,能不能不新建?** 不能,这是平台能力边界而非配置项:DSH 提供的「回到某条消息之前」唯一通道是 `sessions.fork`,fork 必然产生新会话——客户端会话面只有 fork / open / list / binding,没有删除或就地截断会话的动词。插件能控制的是**原会话**的去留(`archiveOriginal`,默认归档到「已归档」、可恢复),不是「是否新建」。若你说的「分支」指的是 git 分支:插件从不创建分支,快照只打 `snap-<消息ID>` tag,不碰项目自身的 git 状态。
|
|
213
|
+
|
|
210
214
|
## 工作原理
|
|
211
215
|
|
|
212
216
|
每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时先打「回退前」安全快照、再用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制与换行符安全,全程不触碰项目自身的 git 状态。
|
package/lib/client.js
CHANGED
|
@@ -2029,9 +2029,12 @@
|
|
|
2029
2029
|
if (recall.stage === "confirm") {
|
|
2030
2030
|
const changes = recall.changes || [];
|
|
2031
2031
|
const total = typeof recall.total === "number" ? recall.total : changes.length;
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2032
|
+
let counts = recall.counts || null;
|
|
2033
|
+
if (!counts && !recall.truncated) {
|
|
2034
|
+
counts = { modified: 0, restored: 0, added: 0 };
|
|
2035
|
+
for (const c of changes) {
|
|
2036
|
+
if (c && isChangeKind(c.kind)) counts[c.kind]++;
|
|
2037
|
+
}
|
|
2035
2038
|
}
|
|
2036
2039
|
const rows = changes.map((c, i) => {
|
|
2037
2040
|
const info = KIND_INFO[c.kind];
|
|
@@ -2053,7 +2056,7 @@
|
|
|
2053
2056
|
t(sessionOnly ? "recall.truncated.chat" : "recall.truncated.files", { shown: changes.length, total })
|
|
2054
2057
|
));
|
|
2055
2058
|
}
|
|
2056
|
-
const summary = summaryText(counts, t);
|
|
2059
|
+
const summary = counts ? summaryText(counts, t) : "";
|
|
2057
2060
|
const rollbackLead = recall.time ? t("recall.confirm.rollbackAt", { time: clockText2(recall.time) }) : t("recall.confirm.rollbackNoTime");
|
|
2058
2061
|
const filesLead = t("recall.confirm.files", {
|
|
2059
2062
|
total,
|
|
@@ -2518,6 +2521,8 @@
|
|
|
2518
2521
|
treeId: res.treeId || null,
|
|
2519
2522
|
time: res.time || null,
|
|
2520
2523
|
cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null,
|
|
2524
|
+
// 全量分类计数(changes 是截断后的清单,不能拿来统计)
|
|
2525
|
+
counts: res.counts || null,
|
|
2521
2526
|
// issue #31:仅消息条目没有文件树(老 Host 无该字段 → 按 true)
|
|
2522
2527
|
files: res.files !== false
|
|
2523
2528
|
});
|
|
@@ -2571,6 +2576,7 @@
|
|
|
2571
2576
|
treeId: res2.treeId || null,
|
|
2572
2577
|
time: res2.time || null,
|
|
2573
2578
|
cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null,
|
|
2579
|
+
counts: res2.counts || null,
|
|
2574
2580
|
files: res2.files !== false
|
|
2575
2581
|
});
|
|
2576
2582
|
}).catch((error) => {
|
package/lib/diagnostics.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const ENV_PATTERNS = [
|
|
2
|
+
// pathTooLong 排最前:它既不是 git 缺失也不是权限问题,是 Windows 的
|
|
3
|
+
// MAX_PATH 硬限制(工作区根 ≥260 字符时 git 直接拒绝索引,实测 259 已
|
|
4
|
+
// 报 "must be run in a work tree")。混进其他分类只会把人引去装 git
|
|
5
|
+
["pathTooLong", [/filename too long/i, /directory name is invalid/i, /path (?:is )?too long/i]],
|
|
2
6
|
["git", [/command not found/i, /not recognized/i, /git: not found/i, /is not a git command/i]],
|
|
3
7
|
["space", [/no space left on device/i, /disk quota exceeded/i, /enospc/i]],
|
|
4
8
|
["permission", [/permission denied/i, /operation not permitted/i, /not permitted/i, /access is denied/i]],
|
|
@@ -10,7 +14,10 @@ const ENV_HINTS = {
|
|
|
10
14
|
space: "\u78C1\u76D8\u7A7A\u95F4\u5DF2\u6EE1\uFF0C\u5FEB\u7167\u5199\u5165\u5931\u8D25\uFF1A\u6E05\u7406\u78C1\u76D8\u7A7A\u95F4\u540E\u81EA\u52A8\u6062\u590D",
|
|
11
15
|
permission: "\u5FEB\u7167\u76EE\u5F55\u65E0\u5199\u5165\u6743\u9650\uFF1A\u8BF7\u68C0\u67E5\u76EE\u5F55\u6743\u9650\u540E\u91CD\u8BD5",
|
|
12
16
|
lock: "\u7591\u4F3C\u591A\u4E2A DSH \u5B9E\u4F8B\u5E76\u53D1\u4F7F\u7528\u540C\u4E00\u5FEB\u7167\u5E93\uFF1A\u8BF7\u786E\u8BA4\u53EA\u542F\u52A8\u4E86\u4E00\u4E2A\uFF1B\u786E\u8BA4\u540E\u4ECD\u5931\u8D25\u65F6\uFF0C\u6309\u300C\u8BBE\u7F6E \xB7 \u63D2\u4EF6\u914D\u7F6E \xB7 \u6700\u8FD1\u9519\u8BEF\u300D\u4E2D\u7684\u8DEF\u5F84\u5220\u9664\u9501\u6587\u4EF6",
|
|
13
|
-
mkdir: "\u5FEB\u7167\u5B58\u50A8\u76EE\u5F55\u88AB\u540C\u540D\u6587\u4EF6\u5360\u7528\uFF1A\u5904\u7406\u540E\u81EA\u52A8\u6062\u590D"
|
|
17
|
+
mkdir: "\u5FEB\u7167\u5B58\u50A8\u76EE\u5F55\u88AB\u540C\u540D\u6587\u4EF6\u5360\u7528\uFF1A\u5904\u7406\u540E\u81EA\u52A8\u6062\u590D",
|
|
18
|
+
// 实测边界:Windows 上工作区根 ≥260 字符时 git 拒绝索引(\\?\ 前缀只救
|
|
19
|
+
// 工作区内的深文件,救不了根前缀本身),插件无法绕过——只能请用户挪路径
|
|
20
|
+
pathTooLong: "\u5DE5\u4F5C\u533A\u8DEF\u5F84\u8D85\u8FC7 Windows 260 \u5B57\u7B26\u4E0A\u9650\uFF0Cgit \u65E0\u6CD5\u7D22\u5F15\uFF1A\u8BF7\u628A\u9879\u76EE\u79FB\u5230\u66F4\u77ED\u7684\u8DEF\u5F84\u540E\u91CD\u8BD5"
|
|
14
21
|
};
|
|
15
22
|
function classifyEnvError(text) {
|
|
16
23
|
const s = String(text || "");
|
package/lib/routes-core.js
CHANGED
|
@@ -54,7 +54,7 @@ function createRoutesCore(deps) {
|
|
|
54
54
|
if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
|
|
55
55
|
const snap2 = state.snapshots.get(id);
|
|
56
56
|
const cutSeq = await snaps.resolveCutSeq(sessionId, id);
|
|
57
|
-
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq, files: snap2 ? snap2.files !== false : void 0 };
|
|
57
|
+
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq, counts: result.counts, files: snap2 ? snap2.files !== false : void 0 };
|
|
58
58
|
},
|
|
59
59
|
"execute": async (args) => {
|
|
60
60
|
const id = args && args.messageId ? String(args.messageId) : "";
|
package/lib/scripts.posix.js
CHANGED
|
@@ -246,7 +246,7 @@ function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
246
246
|
"addrc=0",
|
|
247
247
|
'add_log=$("$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors "${pa_args[@]}" 2>&1) || addrc=$?',
|
|
248
248
|
`if [ "$addrc" -ge 2 ]; then printf '%s\\n' "$add_log" >&2; exit "$addrc"; fi`,
|
|
249
|
-
`printf '%s\\n' "$add_log" | sed -n "s/^error: unable to index file '\\(.*\\)'$/\\1/p" | sort -u | while IFS= read -r sk; do`,
|
|
249
|
+
`printf '%s\\n' "$add_log" | sed -n -e "s/^error: unable to index file '\\(.*\\)'$/\\1/p" -e "s/^error: unable to add '\\(.*\\)' to index$/\\1/p" | sort -u | while IFS= read -r sk; do`,
|
|
250
250
|
// 循环体用 if/fi 而非 && 列表:&& 列表条件为假时整条管道退出码为 1,
|
|
251
251
|
// set -e 会把脚本杀掉(if 语句天然豁免)
|
|
252
252
|
` if [ -n "$sk" ]; then printf 'SNAP_SKIP %s\\n' "$sk"; fi`,
|
|
@@ -333,8 +333,8 @@ function rollbackScript(root, store, gitExe, tag, base) {
|
|
|
333
333
|
// mtime = 解包时刻)。必须如此:tar 默认保留归档内 mtime,而快照→篡改→
|
|
334
334
|
// 回滚常在数秒内完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录
|
|
335
335
|
// 碰撞,下一次 add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区
|
|
336
|
-
// 内容与快照从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows
|
|
337
|
-
//
|
|
336
|
+
// 内容与快照从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 逐条
|
|
337
|
+
// 解压也会显式 touch,两侧语义由各自的回退冒烟测试锁定。
|
|
338
338
|
' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
|
|
339
339
|
" else",
|
|
340
340
|
" batch=()",
|
package/lib/scripts.pwsh.js
CHANGED
|
@@ -5,6 +5,32 @@ const UTF8_PRELUDE = "$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry {
|
|
|
5
5
|
const MAX_FILE_BYTES = 8388608;
|
|
6
6
|
const STALE_LOCK_MIN = 5;
|
|
7
7
|
const HEARTBEAT_TTL_S = 900;
|
|
8
|
+
const WIN_RESERVED_EXCLUDES = [
|
|
9
|
+
"nul",
|
|
10
|
+
"nul.*",
|
|
11
|
+
"aux",
|
|
12
|
+
"aux.*",
|
|
13
|
+
"con",
|
|
14
|
+
"con.*",
|
|
15
|
+
"prn",
|
|
16
|
+
"prn.*",
|
|
17
|
+
"com[1-9]",
|
|
18
|
+
"com[1-9].*",
|
|
19
|
+
"lpt[1-9]",
|
|
20
|
+
"lpt[1-9].*",
|
|
21
|
+
"com\xB9",
|
|
22
|
+
"com\xB9.*",
|
|
23
|
+
"com\xB2",
|
|
24
|
+
"com\xB2.*",
|
|
25
|
+
"com\xB3",
|
|
26
|
+
"com\xB3.*",
|
|
27
|
+
"lpt\xB9",
|
|
28
|
+
"lpt\xB9.*",
|
|
29
|
+
"lpt\xB2",
|
|
30
|
+
"lpt\xB2.*",
|
|
31
|
+
"lpt\xB3",
|
|
32
|
+
"lpt\xB3.*"
|
|
33
|
+
];
|
|
8
34
|
const FIDELITY_ATTRS = "* -text -filter -ident -export-ignore -export-subst -working-tree-encoding";
|
|
9
35
|
function stripBom(text) {
|
|
10
36
|
return text.replace(/^\uFEFF/, "");
|
|
@@ -180,7 +206,8 @@ function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
|
|
|
180
206
|
].join("\n");
|
|
181
207
|
}
|
|
182
208
|
function excludeSyncBlock(excludeFile, base) {
|
|
183
|
-
const
|
|
209
|
+
const configuredBase = Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"];
|
|
210
|
+
const baseList = [...configuredBase, ...WIN_RESERVED_EXCLUDES].map(psq).join(",");
|
|
184
211
|
return [
|
|
185
212
|
"$exFile = " + psq(excludeFile),
|
|
186
213
|
"$userPats = @()",
|
|
@@ -317,14 +344,14 @@ function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
317
344
|
// native stderr 包装成 ErrorRecord,EAP=Stop 下直接抛 NativeCommandError,
|
|
318
345
|
// 而 PS 5.1 的 SilentlyContinue 会把合并流里的记录整个丢弃(实测 LOG
|
|
319
346
|
// 为空)——Continue 是两个版本下唯一都能拿到 stderr 文本的取值。捕获
|
|
320
|
-
// 后按 "unable to index file 'X'"
|
|
321
|
-
//
|
|
347
|
+
// 后按 "unable to index file 'X'" / "unable to add 'X' to index" 提取
|
|
348
|
+
// 被跳过的路径,以 SNAP_SKIP 行回传 JS 侧做用户可见提示。
|
|
322
349
|
"$ErrorActionPreference = 'Continue'",
|
|
323
350
|
'$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors @paArgs 2>&1) | ForEach-Object { [string]$_ }) -join "`n"',
|
|
324
351
|
"$addRc = $LASTEXITCODE",
|
|
325
352
|
"$ErrorActionPreference = 'Stop'",
|
|
326
353
|
'if ($addRc -ge 2) { throw ("git add fatal (exit " + $addRc + "): " + $addLog) }',
|
|
327
|
-
`foreach ($m in [regex]::Matches($addLog, "unable to index file '([^']+)'") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }`,
|
|
354
|
+
`foreach ($m in [regex]::Matches($addLog, "unable to (?:index file|add) '([^']+)'") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }`,
|
|
328
355
|
dropGitlinksBlock(),
|
|
329
356
|
// 兜底:旧 git 走裸 add 时大文件仍会被重新索引,这里按清单再清一次;
|
|
330
357
|
// pathspec 生效时它是空操作(清单条目本就不在 index)
|
|
@@ -381,26 +408,56 @@ function diffScript(root, store, gitExe, tag, base, maxChanges) {
|
|
|
381
408
|
" $targetMap[$path] = $sha",
|
|
382
409
|
"}",
|
|
383
410
|
"$result = @()",
|
|
411
|
+
"$nMod = 0; $nRes = 0; $nAdd = 0",
|
|
384
412
|
"foreach ($k in $curMap.Keys) {",
|
|
385
|
-
' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" } }',
|
|
386
|
-
' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" } }',
|
|
413
|
+
' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" }; $nAdd++ }',
|
|
414
|
+
' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" }; $nMod++ }',
|
|
387
415
|
"}",
|
|
388
416
|
"foreach ($k in $targetMap.Keys) {",
|
|
389
|
-
' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
|
|
417
|
+
' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" }; $nRes++ }',
|
|
390
418
|
"}",
|
|
391
419
|
"$sorted = @($result | Sort-Object rel)",
|
|
420
|
+
// 分类计数必须在截断前出:面板摘要若按前 N 条统计,大变更时「修改 448」
|
|
421
|
+
// 会与总数 3054 并列展示,读起来像清单丢了条目(实测事故形态)
|
|
422
|
+
"Write-Output ('COUNTS ' + $nMod + ' ' + $nRes + ' ' + $nAdd)",
|
|
392
423
|
"Write-Output ('TOTAL ' + $sorted.Count)",
|
|
393
424
|
"Write-Output (ConvertTo-Json -InputObject @($sorted | Select-Object -First " + take + ") -Depth 3 -Compress)",
|
|
394
425
|
"$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()",
|
|
395
426
|
"Write-Output ('TREE ' + $tree)"
|
|
396
427
|
].join("\n");
|
|
397
428
|
}
|
|
429
|
+
function expandZipBlock() {
|
|
430
|
+
return [
|
|
431
|
+
"Add-Type -AssemblyName System.IO.Compression.FileSystem",
|
|
432
|
+
"function Expand-ZipTo {",
|
|
433
|
+
" $now = [DateTime]::UtcNow",
|
|
434
|
+
" $made = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)",
|
|
435
|
+
" $za = [System.IO.Compression.ZipFile]::Open($zip, 'Read', [System.Text.Encoding]::UTF8)",
|
|
436
|
+
" try {",
|
|
437
|
+
" foreach ($e in $za.Entries) {",
|
|
438
|
+
" $t = $lpRoot + '\\' + ($e.FullName -replace '/','\\')",
|
|
439
|
+
" if ($e.Name) {",
|
|
440
|
+
" $d = $t.Substring(0, $t.LastIndexOf('\\'))",
|
|
441
|
+
" if ($d -and $made.Add($d)) { [System.IO.Directory]::CreateDirectory($d) | Out-Null }",
|
|
442
|
+
" [System.IO.Compression.ZipFileExtensions]::ExtractToFile($e, $t, $true)",
|
|
443
|
+
" [System.IO.File]::SetLastWriteTimeUtc($t, $now)",
|
|
444
|
+
" }",
|
|
445
|
+
" }",
|
|
446
|
+
" } finally { $za.Dispose() }",
|
|
447
|
+
"}"
|
|
448
|
+
].join("\n");
|
|
449
|
+
}
|
|
398
450
|
function rollbackScript(root, store, gitExe, tag, base) {
|
|
399
451
|
return [
|
|
400
452
|
"$ErrorActionPreference = 'Stop'",
|
|
401
453
|
"$git = " + psq(gitExe),
|
|
402
454
|
"$g = " + psq(store.git),
|
|
403
455
|
"$root = " + psq(root),
|
|
456
|
+
// PS5.1 的 .NET Framework 不认清单级长路径;\\?\ 前缀绕过 MAX_PATH 且不再
|
|
457
|
+
// 规范化路径,必须先把 / 归一为 \,UNC 再改写为 \\?\UNC\。解压与删除共用。
|
|
458
|
+
"$lpRoot = $root.Replace('/','\\')",
|
|
459
|
+
String.raw`$lpRoot = if ($lpRoot.StartsWith('\\?\')) { $lpRoot } elseif ($lpRoot.StartsWith('\\')) { '\\?\UNC\' + $lpRoot.Substring(2) } else { '\\?\' + $lpRoot }`,
|
|
460
|
+
"$lpRoot = $lpRoot.TrimEnd('\\')",
|
|
404
461
|
dropGitlinksBlock(),
|
|
405
462
|
excludeSyncBlock(store.excludeFile, base),
|
|
406
463
|
// issue #29:同 snapshotScript(回退前的当前清单同样不该为大文件写对象)
|
|
@@ -437,23 +494,24 @@ function rollbackScript(root, store, gitExe, tag, base) {
|
|
|
437
494
|
"$restored = $restoreRel.Count",
|
|
438
495
|
"if ($restored -gt 0) {",
|
|
439
496
|
" $zip = " + psq(store.dir + "\\restore-tmp.zip"),
|
|
497
|
+
expandZipBlock(),
|
|
440
498
|
" if ($restoreRel.Count * 2 -ge $targetMap.Count) {",
|
|
441
499
|
" & $git --git-dir=$g archive --format=zip --output=$zip " + psq(tag),
|
|
442
500
|
" if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
|
|
443
|
-
" Expand-
|
|
501
|
+
" Expand-ZipTo",
|
|
444
502
|
" Remove-Item -LiteralPath $zip -Force",
|
|
445
503
|
" } else {",
|
|
446
504
|
" for ($i = 0; $i -lt $restoreRel.Count; $i += 100) {",
|
|
447
505
|
" $batch = $restoreRel.GetRange($i, [Math]::Min(100, $restoreRel.Count - $i))",
|
|
448
506
|
" & $git --literal-pathspecs --git-dir=$g archive --format=zip --output=$zip " + psq(tag) + " -- $batch",
|
|
449
507
|
" if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
|
|
450
|
-
" Expand-
|
|
508
|
+
" Expand-ZipTo",
|
|
451
509
|
" Remove-Item -LiteralPath $zip -Force",
|
|
452
510
|
" }",
|
|
453
511
|
" }",
|
|
454
512
|
"}",
|
|
455
|
-
// 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下
|
|
456
|
-
//
|
|
513
|
+
// 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下 File.Delete 失败
|
|
514
|
+
// 直接抛终止错误、pwsh 以非零码退出;POSIX 侧 rm 在 set -e 的 if
|
|
457
515
|
// 条件里失败不会自动终止,须显式 exit 1(见 scripts.posix.js rollbackScript)。
|
|
458
516
|
// 任何一侧半回退都不许报 ROLLBACK_OK——假成功会让救援永不触发(H1)。
|
|
459
517
|
"$deleted = 0",
|
|
@@ -461,8 +519,12 @@ function rollbackScript(root, store, gitExe, tag, base) {
|
|
|
461
519
|
" if (-not $r) { continue }",
|
|
462
520
|
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
463
521
|
" if (-not $targetMap.ContainsKey($path)) {",
|
|
464
|
-
" $full =
|
|
465
|
-
" if (
|
|
522
|
+
" $full = $lpRoot + '\\' + ($path.Replace('/','\\'))",
|
|
523
|
+
" if ([System.IO.File]::Exists($full)) {",
|
|
524
|
+
" $attrs = [System.IO.File]::GetAttributes($full)",
|
|
525
|
+
" if (($attrs -band [System.IO.FileAttributes]::ReadOnly) -ne 0) { [System.IO.File]::SetAttributes($full, [System.IO.FileAttributes]::Normal) }",
|
|
526
|
+
" [System.IO.File]::Delete($full); $deleted++",
|
|
527
|
+
' } elseif ([System.IO.Directory]::Exists($full)) { throw ("cannot remove directory at tracked file path: " + $path) }',
|
|
466
528
|
" }",
|
|
467
529
|
"}",
|
|
468
530
|
"Write-Output ('ROLLBACK_OK ' + $deleted + ' ' + $restored)"
|
package/lib/snapshots.js
CHANGED
|
@@ -69,16 +69,38 @@ function parseTreeId(out) {
|
|
|
69
69
|
}
|
|
70
70
|
return null;
|
|
71
71
|
}
|
|
72
|
+
function countChanges(list) {
|
|
73
|
+
const counts = { modified: 0, restored: 0, added: 0 };
|
|
74
|
+
for (const c of list) {
|
|
75
|
+
if (c && Object.prototype.hasOwnProperty.call(counts, String(c.kind))) counts[c.kind]++;
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
function parseCounts(line) {
|
|
80
|
+
const parts = line.trim().split(/\s+/);
|
|
81
|
+
if (parts.length !== 3) return null;
|
|
82
|
+
const m = parseInt(parts[0] ?? "", 10);
|
|
83
|
+
const r = parseInt(parts[1] ?? "", 10);
|
|
84
|
+
const a = parseInt(parts[2] ?? "", 10);
|
|
85
|
+
if (!Number.isFinite(m) || !Number.isFinite(r) || !Number.isFinite(a)) return null;
|
|
86
|
+
if (m < 0 || r < 0 || a < 0) return null;
|
|
87
|
+
return { modified: m, restored: r, added: a };
|
|
88
|
+
}
|
|
72
89
|
function parseDiffOutput(text, isWin, maxChanges) {
|
|
73
90
|
const lines = String(text || "").split(/\r?\n/);
|
|
74
91
|
let total = null;
|
|
75
92
|
let treeId = null;
|
|
93
|
+
let counts = null;
|
|
76
94
|
const body = [];
|
|
77
95
|
for (const line of lines) {
|
|
78
96
|
if (line.indexOf("TREE ") === 0) {
|
|
79
97
|
treeId = line.slice(5).trim() || null;
|
|
80
98
|
continue;
|
|
81
99
|
}
|
|
100
|
+
if (line.indexOf("COUNTS ") === 0) {
|
|
101
|
+
counts = parseCounts(line.slice(7));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
82
104
|
if (line.indexOf("TOTAL ") === 0) {
|
|
83
105
|
const n = parseInt(line.slice(6), 10);
|
|
84
106
|
if (Number.isFinite(n) && n >= 0) total = n;
|
|
@@ -90,7 +112,9 @@ function parseDiffOutput(text, isWin, maxChanges) {
|
|
|
90
112
|
const all = raw ? parseChanges(raw, isWin) : [];
|
|
91
113
|
const finalTotal = total !== null ? total : all.length;
|
|
92
114
|
const changes = all.slice(0, maxChanges);
|
|
93
|
-
|
|
115
|
+
const truncated = finalTotal > changes.length;
|
|
116
|
+
const complete = all.length >= finalTotal;
|
|
117
|
+
return { changes, total: finalTotal, truncated, treeId, counts: counts || (complete ? countChanges(all) : null) };
|
|
94
118
|
}
|
|
95
119
|
function scanCutSeqDetail(events, messageId) {
|
|
96
120
|
let anchor = -1;
|
|
@@ -540,12 +564,12 @@ function createSnapshots(ctx, rt, config) {
|
|
|
540
564
|
async function diffFor(messageId) {
|
|
541
565
|
const snap = state.snapshots.get(String(messageId));
|
|
542
566
|
if (!snap) return null;
|
|
543
|
-
if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null };
|
|
567
|
+
if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null, counts: { modified: 0, restored: 0, added: 0 } };
|
|
544
568
|
const store = state.stores.get(snap.root);
|
|
545
569
|
if (!store) return null;
|
|
546
570
|
const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe || "", "snap-" + messageId, BASE(), MAX_CHANGES), { timeoutMs: 6e5, stdoutMaxBytes: 8388608 }));
|
|
547
571
|
const trimmed = text.trim();
|
|
548
|
-
if (!trimmed) return { changes: [], total: 0, truncated: false, treeId: null };
|
|
572
|
+
if (!trimmed) return { changes: [], total: 0, truncated: false, treeId: null, counts: { modified: 0, restored: 0, added: 0 } };
|
|
549
573
|
return parseDiffOutput(trimmed, rt.isWin, MAX_CHANGES);
|
|
550
574
|
}
|
|
551
575
|
async function rollbackFor(messageId) {
|
package/package.json
CHANGED