dsh-rewind-plugin 0.14.0-beta.1 → 0.14.0-beta.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.
@@ -1,103 +1,220 @@
1
1
  # File-rewind tracking boundary
2
2
 
3
- > Behavioral note — this is **not** a bug. The plugin's file rewind deliberately
4
- > tracks only the model's dedicated editing tools, and applies an additional
5
- > boundary (message-boundary re-check) to registered files. Written for issue
6
- > [#5](https://github.com/SiriLee/dsh-rewind/issues/5); aligned with Claude Code's
7
- > checkpoint semantics. [中文](tracking-boundary.zh.md)
8
-
9
- ## One-line summary
10
-
11
- Rewind **always** restores files edited with the dedicated editing tools
12
- (`write` / `edit`) because the plugin snapshots them before the edit.
13
- Command-line (or hand) edits are covered **only when the file was previously
14
- registered** by an editing tool in the same conversation — and, in that case,
15
- the change itself is recorded, but rewinding to a boundary inside an unchanged
16
- interval is only as precise as the next recorded state (see
17
- [Rewind precision](#rewind-precision-for-a-registered-file-changed-outside-editing-tools)).
18
-
19
- ## How file changes are classified
20
-
21
- **Editing tools** (create/overwrite, line edits): each call tells the
22
- plugin *which file* it will change, so the plugin snapshots the file's current
23
- content **before** the edit and can restore it on rewind. This path is
24
- deterministic — using them always yields a restorable rewind.
25
-
26
- **Command-line or manual edits**: PowerShell (`pwsh`) writes on Windows, `sed -i`
27
- on Linux, or saving a file by hand. These do **not** tell the plugin which file
28
- is being touched, so there is nothing to snapshot beforehand.
29
-
30
- Command/manual edits are nevertheless covered in one case: if the file was
31
- **previously registered** by an editing tool in this conversation, the plugin
32
- re-checks registered files at each user-message boundary and records a change it
33
- sees, so those files restore on rewind too.
34
-
35
- ## When a rewind is not available
36
-
37
- These are cases where there is **no recorded pre-change state at all** (as
38
- opposed to the precision gap in the next section, where a state exists but an
39
- intermediate message's exact value is lost):
40
-
41
- 1. **The model edits the file with a command on its first touch, and the file
42
- was never registered.** No snapshot, no registration — nothing was recorded
43
- around the change, so there is nothing to restore from. Every rewind target
44
- reports no restorable change for this file.
45
- 2. **The file was manually edited before the model's first editing-tool change.**
46
- The plugin's first snapshot is taken at the editing-tool call, so rewind can
47
- return the file only to its state *after* the manual edit, not before it.
48
-
49
- Both stem from a deliberate **lightweight** trade-off. Supporting command edits
50
- in every case would require scanning the whole workspace and classifying what
51
- arbitrary commands changed — costly, error-prone, hard to maintain. Instead the
52
- plugin registers only the editing tools that hand it an exact path, and relies
53
- on the message-boundary re-check as its lightweight safety net. The cost is that
54
- unregistered files' command edits are not recorded; the benefit is a simple,
55
- reliable, maintainable plugin — the same behavior Claude Code exhibits.
56
-
57
- ## Rewind precision for a registered file changed outside editing tools
58
-
59
- A registered file's snapshots come only from (a) an editing tool's before-capture
60
- and (b) the boundary's change-detection. So a message is a *recording point* for
61
- a file only when the file's state differs from its most recent recorded state;
62
- rewinding to message *M* restores each file to the earliest entry at/after *M*.
63
-
64
- If the file was **unchanged** at *M* (relative to its last record), then *M* is
65
- **not** a recording point. If the file is later changed by a command/manual
66
- edit, the earliest entry at/after *M* is the one that recorded the **post-change**
67
- state — so rewinding to *M* restores that post-change state, not the state the
68
- file actually held at *M*:
69
-
70
- ```
71
- message 1 write sets f = "X" records before=null (file created)
72
- message 2 boundary records f = "X" (null → X)
73
- a command changes f "X" → "Y"
74
- message 3 boundary records f = "Y" (X → Y)
75
- rewind to message 2 → restores "X" (correct: f was "X" at message 2)
76
- ```
77
-
78
- Now insert one *unchanged* message, and the precision gap appears:
79
-
80
- ```
81
- message 1 write sets f = "X" records before=null (file created)
82
- message 2 boundary records f = "X" (null → X)
83
- message 3 reply, f still "X" NOT a recording point (unchanged)
84
- a command changes f "X" → "Y"
85
- message 4 boundary records f = "Y" (X → Y)
86
- rewind to message 3 → restores "Y" (wrong: f was "X" at message 3)
87
- ```
88
-
89
- Message 2 is a recording point, so rewinding to it recovers the pre-change state
90
- `X`; message 3 is not, so its exact state is lost once the command changes the
91
- file. Only recording every registered file at every message — even when nothing
92
- changed — would close this, at the cost of a per-message file per registered
93
- file. The plugin avoids that to stay lightweight (see
94
- [Position](#position)). Claude Code's checkpoints behave the same way.
95
-
96
- ## Position
97
-
98
- Making command edits restorable in the *unregistered-first-touch* case would
99
- mean repeatedly backing up the entire workspace, or depending on a Git working
100
- tree — both heavy, and at odds with the plugin's lightweight-snapshot design.
101
- Git is already the right tool for workspace-change management. The plugin's
102
- position is to favor **fast, conversation-scoped rewinds**, so this capability
103
- is deliberately not adopted.
3
+ > When it rewinds files, the plugin **writes back only content it actually backed
4
+ > up**: a file changed by a write-class tool (`write`, `edit`) can always be restored
5
+ > exactly, while a stretch of state that **was not backed up** makes the restore
6
+ > **incomplete** — it prefers to do nothing rather than guess.
7
+ >
8
+ > This document describes how it works, the two known limitations that follow, and
9
+ > why they are accepted. All of it is a **deliberate trade-off** (matching Claude
10
+ > Code's checkpoint semantics), not a defect.
11
+
12
+ ## 1. The two modes of file change
13
+
14
+ A file in DSH can change in exactly two ways, and the plugin observes them very
15
+ differently.
16
+
17
+ **The model calls a write-class tool** (`write`, `edit`): the call's arguments carry
18
+ the exact path, so the plugin can capture the file's current content **before** the
19
+ write lands. This path is deterministic — once handled, the file is certainly
20
+ restorable.
21
+
22
+ **An external edit** (a command-line Bash, a PowerShell write, a build script
23
+ rewriting files, or saving by hand in an editor): nothing declares the modified path
24
+ to the plugin, so **there is no object to capture in advance**; the plugin can only
25
+ discover it after the fact.
26
+
27
+ ## 2. How it works
28
+
29
+ ### One backup = the state when one message began
30
+
31
+ The plugin does not store the workspace's current state; it stores **state
32
+ transitions**. Every backup answers one question:
33
+
34
+ > **What was this file when that message began?**
35
+
36
+ Its content has two forms: `null` means the file **did not exist** (a restore then
37
+ deletes it), otherwise it is a full copy. Together these backups are the session's
38
+ **snapshot** (grouped by message). A backup belongs to **the user message that
39
+ triggered it**; within a single message's turn, every write-class tool call leaves a
40
+ backup of its own, and a restore takes the **earliest** of them.
41
+
42
+ ### Where backups come from: the tracked list
43
+
44
+ The plugin does not scan the workspace; it follows one **tracked list**: a file joins
45
+ it as soon as a write-class tool handles it **once**; from then on, every new user
46
+ message makes the plugin re-check the files **on that list**; a file that never joins
47
+ it has no backup at all.
48
+
49
+ Backups come from two sources:
50
+
51
+ - **Before-write backup**: when the model calls a write-class tool, the plugin takes
52
+ the current content **before** the change lands and stores it under **the user
53
+ message that started this turn**;
54
+ - **Message-boundary re-check**: when a new message arrives, if a file on the list
55
+ now differs from its **latest backup**, the current content is recorded as **that
56
+ message's** backup. This is the only way an external edit gets backed up.
57
+
58
+ ### Two cases where no backup is produced
59
+
60
+ - **Unchanged, not stored**: when the boundary re-check finds the disk identical to
61
+ the latest backup, this re-check writes nothing; if a write-class tool edits that
62
+ file later in the same turn, that before-write backup is still recorded. A message
63
+ that changed no file therefore produces no backup of its own.
64
+ - **Over the cap, not stored**: before writing a backup, if a single file is found to
65
+ exceed the size cap (8 MiB by default, adjustable via `DSH_REWIND_MAX_FILE_BYTES`,
66
+ `0` disables it), the plugin **refuses the backup**; until that file is back under
67
+ the cap, no new backup is written for it (the earlier backups remain).
68
+
69
+ ### Restore: backups are the only standard
70
+
71
+ A restore reads nothing else in the workspace and infers nothing:
72
+
73
+ 1. **Find the backup**: per file, take the **earliest** backup at or after the
74
+ **target message**; when the target message has no backup of its own, look toward
75
+ the later messages for the earliest one.
76
+ 2. **Compare with the disk**: if it matches the current disk content, nothing is
77
+ done; otherwise the backup's content is written back (a `null` backup deletes the
78
+ file).
79
+
80
+ Looking toward later messages works because "unchanged, not stored" means the target
81
+ message began from the same state as its latest backup — so the earliest later backup
82
+ holds exactly that state (the premise being that nothing between the target message
83
+ and that backup went unrecorded; see limitation 2). Conversely, the nearest backup
84
+ *before* the target is not used because it may predate a **coverage gap** (limitation
85
+ 2, case 3): writing it back would overwrite the current file with an older version.
86
+ **Restore less rather than write something wrong.**
87
+
88
+ ### An example
89
+
90
+ | Msg | In the turn | File content | Backup: what it was when that message began |
91
+ | :---: | --- | :---: | --- |
92
+ | 1 | `write` creates it | `—` → `A` | `null` (the file did not exist before the write; backed up before the write and added to tracking) |
93
+ | 2 | — | `A` | `A` (the disk differs from the previous backup `null`) |
94
+ | 3 | — | `A` | (none: identical to the latest backup, and nothing was edited in this turn, unchanged, not stored) |
95
+ | 4 | `edit` | `A` → `B` | `A` (the content before that edit, a before-write backup) |
96
+ | 5 | `bash` rewrite | `B` → `C` | `B` (msg 4's edit had already taken effect, so the disk held `B` when msg 5 arrived) |
97
+
98
+ The disk now holds `C`. Rewinding gives: target 1 → the backup is `null`, the file is
99
+ deleted; target 2 → `A`; target 3 → no backup of its own, so looking toward later
100
+ messages finds msg 4's `A`, exactly the state msg 3 began from; target 4 → `A`;
101
+ target 5 → `B`.
102
+
103
+ ## 3. Known limitations
104
+
105
+ Both limitations share one root cause: **a stretch of state was not backed up**.
106
+ Limitation 1 results in **no restore at all**, limitation 2 in an **incomplete
107
+ restore**.
108
+
109
+ ### Limitation 1: history that never entered tracking — no restore
110
+
111
+ **Being handled by a write-class tool is the prerequisite for a file to join the
112
+ tracked list and produce backups.** Before that, every external edit it went through
113
+ has no backup, and neither source will write one for it. Two cases:
114
+
115
+ **Nothing in the session ever handled it.** Rewinding to any message can only report
116
+ "no restorable changes" for that file.
117
+
118
+ **Something handled it later (the more common case).** Backups exist only from that
119
+ moment on, so earlier history cannot be restored — in the example below, `v0` is
120
+ history the plugin has never seen:
121
+
122
+ | Msg | In the turn | File content | Backup: what it was when that message began |
123
+ | :---: | --- | :---: | --- |
124
+ | 1 | — | `v0` | (none: the file has not joined the tracked list) |
125
+ | 2 | a manual edit | `v0` → `v1` | (none: as above) |
126
+ | 3 | a manual edit | `v1` → `v2` | (none: as above) |
127
+ | 4 | `edit`, the first time it is handled | `v2` → `v3` | `v2` (before-write backup: the file joins the tracked list at this moment) |
128
+ | 5 | — | `v3` | `v3` (the disk differs from the previous backup `v2`) |
129
+
130
+ Rewinding to msg 5 → `v3`; to msg 4 → `v2`; and **rewinding to msg 1, 2 or 3 also
131
+ gives `v2`** — the earliest backup after them is msg 4's `v2`. `v2` really is the
132
+ content at the moment the file was first handled, but what those three messages began
133
+ from was `v0`, `v0` and `v1`; the plugin never saw them and cannot restore them.
134
+
135
+ ### Limitation 2: a stretch of state that was not backed up — an incomplete restore
136
+
137
+ Both "unchanged, not stored" and "over the cap, not stored" leave a stretch of state
138
+ without a backup. When the target message has no backup of its own, the plugin looks
139
+ toward later messages for the backup; as soon as an **unrecorded change** happened
140
+ between the target and that backup, what it takes is no longer the state the target
141
+ message began from. The three cases below stack one more scenario each.
142
+
143
+ **Case 1: the change does not belong to any message yet**
144
+
145
+ | Msg | In the turn | File content | Backup |
146
+ | :---: | --- | :---: | --- |
147
+ | 1 | `write` creates it | `—` → `A` | `null` |
148
+ | 2 | — | `A` | `A` (differs from the previous backup `null`) |
149
+ | 3 | — | `A` | (none: identical to the previous backup, and nothing was edited in this turn) |
150
+ | (after) | a manual edit | `A` → `X` | (none: no new message has claimed it yet) |
151
+
152
+ Rewinding to msg 3: neither it nor anything later has a backup, the plugin does not
153
+ know the file changed, so it **does nothing and the file stays at `X`** (msg 3
154
+ actually began with `A`). To recover `A`, rewind to msg 2 — the backup under it is
155
+ `A`. A later message only makes the state **at that time** (`X`) rewindable; the `A`
156
+ msg 3 began from still cannot come back.
157
+
158
+ **Case 2: what is backed up is the state after the change**
159
+
160
+ | Msg | In the turn | File content | Backup |
161
+ | :---: | --- | :---: | --- |
162
+ | 1 | `write` creates it | `—` → `A` | `null` |
163
+ | 2 | — | `A` | `A` (differs from the previous backup `null`) |
164
+ | 3 | — | `A` | (none: identical to the latest backup) |
165
+ | 4 | a command changes it to `B` | `A` → `B` | (none: the disk was still `A` when msg 4 arrived) |
166
+ | 5 | — | `B` | `B` (differs from the previous backup `A`) |
167
+
168
+ Rewinding to msg 3 or 4: neither has a backup of its own, so looking toward later
169
+ messages finds msg 5's `B` — the state **after** the change, while they began with
170
+ `A`. The disk matches that backup, so the plugin does nothing and the file stays at
171
+ `B`; if the file changed again afterwards, the plugin writes `B` (overwriting
172
+ whatever is newer); and if the change in msg 4's turn had been a command **deleting**
173
+ the file, msg 5's backup would be `null` and rewinding to 3 or 4 would **delete the
174
+ current file**. The difference from case 1 is that a message followed this change and
175
+ recorded the state after it — in case 1 nothing was recorded at all.
176
+
177
+ **Case 3: the file exceeded the cap while it changed**
178
+
179
+ | Msg | In the turn | File content | Backup |
180
+ | :---: | --- | :---: | --- |
181
+ | 1 | `write` creates it | `—` → `A` | `null` |
182
+ | 2 | `edit` | `A` → `G1` | `A` (a before-write backup) |
183
+ | 3 | a write-class tool or an external edit | `G1` → `G2` | (none: over the cap, not backed up) |
184
+ | 4 | shrinks back to `B` | `G2` → `B` | (none: still over the cap on arrival, so the re-check skipped it) |
185
+ | 5 | `edit` | `B` → `X` | `B` (a before-write backup; tracking resumes) |
186
+
187
+ In the "small → big → small" scenario the stretch over the cap has no backup at all —
188
+ a **coverage gap**. Rewinding to msg 3 or 4 finds msg 5's `B`, so the plugin writes
189
+ `B`; the "big" state they began from falls inside the gap and can never be restored.
190
+ And if the file is currently over the cap, **no** target touches it: it prefers to do
191
+ nothing rather than write back an older backup.
192
+
193
+ ## 4. Why these limitations are accepted
194
+
195
+ Removing **limitation 1** would require the plugin to know which files an arbitrary
196
+ command or external edit modified. Neither route works: having every external writer
197
+ declare itself (neither `sed`, nor build scripts, nor editors will), or tracking the
198
+ whole workspace and taking a whole-tree snapshot before every change — enormous in
199
+ volume, and precise workspace-level history is exactly the job of dedicated snapshot
200
+ tools such as Git. The plugin only does lightweight rewinding aimed at Agent tooling,
201
+ so this limitation stays.
202
+
203
+ Removing **limitation 2** would mean giving up "unchanged, not stored" and "over the
204
+ cap, not stored": every message boundary would have to record **every** tracked file
205
+ (changed or not), and large files would be backed up in full again and again. That
206
+ cost has two layers: storage grows linearly with the conversation, and messages that
207
+ change no file are the overwhelming majority; and every backup reads and writes a
208
+ whole file, so a very large one saturates disk IO (which is exactly why the per-file
209
+ cap exists).
210
+
211
+ What the two limitations buy is: lightweight, predictable, and **only ever writing
212
+ back content it actually backed up, never a guess**. For precise workspace-level
213
+ history, use Git.
214
+
215
+ ## Background
216
+
217
+ This document was originally written for issue
218
+ [#5](https://github.com/SiriLee/dsh-rewind/issues/5) and completed for issue
219
+ [#39](https://github.com/SiriLee/dsh-rewind/issues/39). See also:
220
+ [SECURITY.md](../../SECURITY.md) · [Snapshot auto-cleanup](../snapshot-auto-cleanup.md)
@@ -1,55 +1,131 @@
1
1
  # 文件回退的追踪边界
2
2
 
3
- > 行为说明——这**不是 bug**。插件的文件回退**有意只追踪模型的专用编辑工具**,并对**已登记文件**额外施加一层边界(消息边界复查)。为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写;与 Claude Code 的 checkpoint 语义保持一致。 [English](tracking-boundary.md)
3
+ > 回退文件时,插件**只写回它确实备份过的内容**:被写类工具改动过的文件始终可以精确还原;而某一段状态**没有被备份**时,还原就会**不充分**——它宁可不动,也不猜测。
4
+ >
5
+ > 本文说明它的原理、由此产生的两项已知限制,以及为什么接受它们。这些都是**有意取舍**(与 Claude Code 的检查点语义一致),不是缺陷。
4
6
 
5
- ## 一句话概括
7
+ ## 一、文件变更的两种模式
6
8
 
7
- 回退**始终能还原**用专用编辑工具(`write` / `edit`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
9
+ 文件在 DSH 中只有两种变化途径,插件对二者的可观测性完全不同。
8
10
 
9
- ## 文件变更的分类
11
+ **模型调用写类工具**(`write`、`edit`):调用参数给出了确切的文件路径,插件能在文件被覆盖**之前**捕获它的当前内容。这条路径是确定性的——一经处理,这个文件就一定可以还原。
10
12
 
11
- **编辑工具**(创建/覆盖、按行编辑):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
13
+ **外部编辑**(命令行 Bash、PowerShell 写入、构建脚本改写,或手动在编辑器里保存):没有任何环节向插件声明被修改的路径,**不存在可以提前捕获的对象**,只能事后发现。
12
14
 
13
- **命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
15
+ ## 二、原理
14
16
 
15
- 命令/手动编辑仍有一种情况会被覆盖:若该文件在本会话中**先前被某个编辑工具登记过**,插件会在每个用户消息边界复查已登记文件并记录它看到的变更,因此这类文件也能在回退时还原。
17
+ ### 一份备份 = 一条消息开始时的状态
16
18
 
17
- ## 何时无法回退
19
+ 插件不保存"工作区当前状态",保存的是**状态转变**。每份备份只回答一个问题:
18
20
 
19
- 这类是**完全没有记录到"变更前"状态**(有别于下一节的"精度缺口"——那是有记录、但某个中间消息的精确值丢失):
21
+ > **这条消息开始时,这个文件是什么?**
20
22
 
21
- 1. **模型第一次触达时用命令编辑,且该文件从未被登记。** 无快照、无登记——变更前后都没有记录,无从还原;回退到任何消息对该文件都报告"无变更可恢复"。
22
- 2. **文件在模型第一次编辑工具改动前就被手动编辑过。** 插件的首个快照是在编辑工具调用时拍的,因此回退只能把文件还原到手动编辑**之后**的状态,而非**之前**。
23
+ 内容有两种形态:`null` 表示文件**不存在**(还原时表现为删除),另一种则是完整副本。这些备份合起来就是这个会话的**快照**(按消息分组存放)。备份归属于**触发它的那条用户消息**;同一条消息的回合里,每次写类工具调用都会留下一份备份,还原时取其中**最早**的一份。
23
24
 
24
- 两者都来自**刻意求轻**的取舍。要覆盖各种命令编辑,就得扫描整个工作区、并分类任意命令改了什么——代价高、易错、难维护。插件只登记会交出手确切路径的编辑工具,并以消息边界复查作为轻量安全网。代价是未登记文件的命令编辑不被记录;收益是简单、可靠、易维护——与 Claude Code 相同。
25
+ ### 备份从哪里来:追踪列表
25
26
 
26
- ## 已登记文件被编辑工具之外的方式改动时的回退精度
27
+ 插件不扫描整个工作区,只认一张**追踪列表**:文件被写类工具处理**一次**即加入;此后每条新的用户消息到达时,只复查名单里的文件;不在名单里的文件没有任何备份。
27
28
 
28
- 已登记文件的快照只来自:(a) 编辑工具的 before 捕获,(b) 边界复查的变更检测。所以**只有"文件状态相对上次记录发生变化"的那条消息才是记录点**;回退到消息 *M* 会把各文件还原到 *M* 之后**最早**的那条记录。
29
+ 备份有两个来源:
29
30
 
30
- 若文件在 *M* 处**未变**(相对上次记录),则 *M* **不是记录点**。若随后文件被命令/手动编辑改掉,*M* 之后最早的那条记录是**变更后**状态——于是回退到 *M* 会还原成**变更后**状态,而非 *M* 时刻文件**真实**的状态:
31
+ - **写前备份**:模型调用写类工具时,在改动落盘**之前**取当前内容,记在**触发这一轮的那条用户消息**名下;
32
+ - **消息边界复查**:新消息到达时,若名单里某个文件当前的磁盘内容与**最近一份备份**不一致,就把当前内容记为**这条消息**的备份。这是外部编辑唯一的入口。
31
33
 
32
- ```
33
- 消息 1 write 设 f="X" 记录 before=null(文件新建)
34
- 消息 2 边界记录 f="X" (null→X)
35
- 命令把 f "X"→"Y"
36
- 消息 3 边界记录 f="Y" (X→Y)
37
- 回退到消息 2 → 还原成 "X" (正确:消息 2 时 f 是 "X")
38
- ```
34
+ ### 两种不产生备份的情形
39
35
 
40
- 插入一条**未变**消息,精度缺口才显现:
36
+ - **不变不存**:边界复查发现磁盘与最近一份备份一致时,这次复查不写备份;若本回合稍后写类工具改动了该文件,那份写前备份照常记录。所以"没有改动文件的消息"自己不产生备份。
37
+ - **超量不存**:准备写备份之前若发现单个文件超过体积上限(默认 8 MiB,可用 `DSH_REWIND_MAX_FILE_BYTES` 调整,设为 `0` 关闭),插件**拒绝备份**;这个文件在回到限制以下之前不会产生新的备份(旧备份仍在)。
41
38
 
42
- ```
43
- 消息 1 write 设 f="X" 记录 before=null(文件新建)
44
- 消息 2 边界记录 f="X" (null→X)
45
- 消息 3 回复,f 仍="X" 不是记录点(未变)
46
- 命令把 f "X"→"Y"
47
- 消息 4 边界记录 f="Y" (X→Y)
48
- 回退到消息 3 → 还原成 "Y" (错:消息 3 时 f 实际是 "X")
49
- ```
39
+ ### 还原:只依据备份
50
40
 
51
- 消息 2 是记录点,所以回退它能恢复**变更前**状态 `X`;消息 3 不是记录点,一旦文件被命令改掉,它那刻的精确状态就丢了。要关闭这个缺口,就得让每条边界都是快照——每条消息都记录每个已登记文件(哪怕没变)——代价是**每条消息×每个文件都写一个文件**。插件**刻意不做**,以保持轻量(见[定位](#定位))。Claude Code 的 checkpoint 同样如此。
41
+ 还原不读工作区的其他内容,也不做推断:
52
42
 
53
- ## 定位
43
+ 1. **找备份**:对每个文件,取**目标消息及其之后最早**的一份备份;目标消息自己没有备份时,就向更晚的消息找最早的那一份。
44
+ 2. **与磁盘比对**:一致 → 不做任何操作;不一致 → 写回备份的内容(`null` → 删除该文件)。
54
45
 
55
- 要把"未登记首触即命令编辑"的情形也做成可还原,意味着对整个工作区反复备份,或依赖 Git 工作树——都笨重,且与插件"轻量快照"的设计相悖。Git 才是管理工作区变更的正确工具。插件的定位是**快速、会话内回退**,因此**刻意不采纳**该能力。
46
+ 向更晚的消息找,是因为"不变不存"意味着目标消息开始时的状态与最近一份备份相同——更晚消息的最早备份,记的正是那一刻的状态(前提是目标消息之后、那份备份之前没有未被记录的改动,见限制二)。反过来,不取"目标消息之前最近"的那份备份,是因为它可能早于一个**缺口**(限制二·表现 3):写回它等于用更旧的版本覆盖当前文件。**宁可少还原,也不写错内容。**
47
+
48
+ ### 示例
49
+
50
+ | 消息 | 回合内发生 | 文件内容 | 备份:这条消息开始时是什么 |
51
+ | :---: | --- | :---: | --- |
52
+ | 1 | `write` 新建 | `—` → `A` | `null`(落盘前文件不存在,写前备份并加入追踪) |
53
+ | 2 | — | `A` | `A`(磁盘与上一份备份 `null` 不一致) |
54
+ | 3 | — | `A` | (无:与最近一份备份一致,且本轮未编辑,不变不存) |
55
+ | 4 | `edit` | `A` → `B` | `A`(本次编辑之前的内容,写前备份) |
56
+ | 5 | `bash` 改写 | `B` → `C` | `B`(消息 5 到达时磁盘已是 `B`,消息 4 的编辑已生效) |
57
+
58
+ 磁盘当前是 `C`。回退结果:目标 1 → 备份为 `null`,删除文件;目标 2 → `A`;目标 3 → 自己没有备份,向更晚的消息找到消息 4 的 `A`,正是消息 3 开始时的状态;目标 4 → `A`;目标 5 → `B`。
59
+
60
+ ## 三、已知限制
61
+
62
+ 两项限制的根因相同:**某一段状态没有被备份**。限制一的结果是**无法还原**,限制二的结果是**还原不充分**。
63
+
64
+ ### 限制一:没有进入追踪的历史 —— 无法还原
65
+
66
+ **"被写类工具处理过"是文件加入追踪列表、产生备份的前提。** 在此之前,它经历的所有外部编辑都没有备份,两个来源也都不会为它写备份。两种情形:
67
+
68
+ **整个会话都没有处理过它。** 回退到任何消息,插件对该文件都只能报告"没有可还原的改动"。
69
+
70
+ **后来处理过它(更常见)。** 从那一刻起才有备份,更早的历史无法还原——下面例子里 `v0` 是插件从未见过的历史:
71
+
72
+ | 消息 | 回合内发生 | 文件内容 | 备份:这条消息开始时是什么 |
73
+ | :---: | --- | :---: | --- |
74
+ | 1 | — | `v0` | (无:文件还没有加入追踪列表) |
75
+ | 2 | 手动编辑 | `v0` → `v1` | (无:同上) |
76
+ | 3 | 手动编辑 | `v1` → `v2` | (无:同上) |
77
+ | 4 | `edit` 首次处理 | `v2` → `v3` | `v2`(写前备份:这一刻文件才加入追踪列表) |
78
+ | 5 | — | `v3` | `v3`(磁盘与上一份备份 `v2` 不一致) |
79
+
80
+ 回退到消息 5 → `v3`;消息 4 → `v2`;而**回退到消息 1、2、3 也都会得到 `v2`**——它们之后最早的备份就是消息 4 的 `v2`。`v2` 确实是"首次处理那一刻"的内容,但这三条消息开始时的真实内容分别是 `v0`、`v0`、`v1`,插件从未见过,无法还原。
81
+
82
+ ### 限制二:有一段状态没有被备份 —— 还原不充分
83
+
84
+ "不变不存"与"超量不存"都会让某一段状态没有备份。目标消息自己没有备份时,插件按"向更晚的消息找"取备份;一旦目标之后、那份备份之前发生过**未被记录的改动**,取到的就不是目标消息开始时的状态。三种表现依次叠加更多场景。
85
+
86
+ **表现 1:变更还没有归属到任何消息**
87
+
88
+ | 消息 | 回合内发生 | 文件内容 | 备份 |
89
+ | :---: | --- | :---: | --- |
90
+ | 1 | `write` 新建 | `—` → `A` | `null` |
91
+ | 2 | — | `A` | `A`(与上一份备份 `null` 不一致) |
92
+ | 3 | — | `A` | (无:与上一份备份一致,且本轮未编辑) |
93
+ | (之后) | 手动修改文件 | `A` → `X` | (无:还没有新消息来认领它) |
94
+
95
+ 回退到消息 3:它自己及其之后都没有备份,插件不知道文件变过,于是**不做任何操作,文件停在 `X`**(消息 3 开始时其实是 `A`)。要取回 `A`,回退到消息 2——它名下的备份就是 `A`。之后再来的消息只会让**当时**的状态(`X`)变得可回退,消息 3 开始时的 `A` 仍然回不来。
96
+
97
+ **表现 2:记下的是变更之后的状态**
98
+
99
+ | 消息 | 回合内发生 | 文件内容 | 备份 |
100
+ | :---: | --- | :---: | --- |
101
+ | 1 | `write` 新建 | `—` → `A` | `null` |
102
+ | 2 | — | `A` | `A`(与上一份备份 `null` 不一致) |
103
+ | 3 | — | `A` | (无:与最近一份备份一致) |
104
+ | 4 | 命令把文件改成 `B` | `A` → `B` | (无:消息 4 到达时磁盘还是 `A`) |
105
+ | 5 | — | `B` | `B`(与上一份备份 `A` 不一致) |
106
+
107
+ 回退到消息 3 或 4:它们自己没有备份,向更晚的消息找到消息 5 的 `B`——那是变更**之后**的状态,而它们开始时是 `A`。此时磁盘与备份一致,插件不做任何操作,文件停在 `B`;若此后文件又变过,插件会把文件写成 `B`(覆盖比它更新的内容);若消息 4 的回合里是命令**删除**文件,消息 5 的备份就是 `null`,回退到 3 或 4 会**删除当前文件**。与表现 1 的差别在于:这次变更之后还有消息,它把变更后的状态记了下来——表现 1 里则一条都没记。
108
+
109
+ **表现 3:变更期间文件超过上限**
110
+
111
+ | 消息 | 回合内发生 | 文件内容 | 备份 |
112
+ | :---: | --- | :---: | --- |
113
+ | 1 | `write` 新建 | `—` → `A` | `null` |
114
+ | 2 | `edit` | `A` → `G1` | `A`(写前备份) |
115
+ | 3 | 写类工具或外部修改 | `G1` → `G2` | (无:超过上限,不备份) |
116
+ | 4 | 缩回 `B` | `G2` → `B` | (无:消息到达时仍超限,跳过复查) |
117
+ | 5 | `edit` | `B` → `X` | `B`(写前备份,重新追踪) |
118
+
119
+ "小 → 大 → 小"的场景中,超过上限的那一段没有任何备份,是一个**缺口**。回退到消息 3、4 时取到的是消息 5 的 `B`——插件会把文件写成 `B`;它们开始时的"大"状态落在缺口里,永远无法还原。若磁盘当前仍然超过上限,**任何**目标都不会动这个文件:宁可不动,也不写回一份更旧的备份。
120
+
121
+ ## 四、为什么接受这些限制
122
+
123
+ **限制一**要消除,得让插件知道任意命令、外部编辑动了哪些文件。两条路都不通:一是让所有外部写入主动申报——`sed`、构建脚本、编辑器都不会这么做;二是全量追踪整个工作区、每次改动前做整树快照——体量极大,而工作区级的精确历史正是 Git 这类专业快照工具的职责。插件只做面向 Agent 工具的轻量回退,因此保留这条限制。
124
+
125
+ **限制二**要消除,必须放弃"不变不存"与"超量不存":每个消息边界都要记录**每个**被追踪文件(即使一点没变),大文件也会被反复完整备份。代价有两层——存储量随对话长度线性增长,而未改动文件的消息占绝大多数;以及每次备份都要完整读写文件,文件很大时会把磁盘 IO 打满(单文件上限正是为此而设)。
126
+
127
+ 两项限制换来的是:轻量、可预测,以及**只写它确实备份过的内容、绝不做任何猜测**。需要工作区级别的精确历史时,请使用 Git。
128
+
129
+ ## 背景
130
+
131
+ 本文最初为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写,并为 issue [#39](https://github.com/SiriLee/dsh-rewind/issues/39) 进行完善。相关:[SECURITY.md](../../SECURITY.md) · [快照自动清理](../snapshot-auto-cleanup.zh.md)