diff --git a/AGENTS.md b/AGENTS.md index a393822..b05239f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ ESM-only, Node ≥22. Toolchain is Vite+ (`vp`), not plain npm: - **Notify only on real change.** A failed `commit`, an empty `transaction`, and a no-op `undo`/`redo`/`jump` notify _nobody_. - **Checkpoints anchor to entries.** Pruned when their entry is evicted (`limit` - overflow) or dropped (redo branch truncated by a fresh commit); `revertTo` - throws on an unknown/pruned name. + overflow) or dropped (redo branch truncated by a fresh commit); eviction also + prunes position-0 checkpoints. `revertTo` throws on an unknown/pruned name. - **Rollback is reverse-order and best-effort.** A throwing inverse stops rollback and surfaces. diff --git a/CLAUDE.md b/CLAUDE.md index 459513c..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1 @@ -# @b2m9/reversible - -The agent guide for this project is maintained in a single source of truth, -`AGENTS.md` (the cross-tool standard). Claude Code reads it via this import: - @AGENTS.md diff --git a/README.md b/README.md index adc0035..8222b65 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,11 @@ history.subscribe((meta) => { /* ... */ }); // returns unsubscribe ``` With `limit`, the oldest entries are evicted past the cap. Checkpoints anchor to -the entry they sit after, so they stay correct as eviction renumbers positions; a -checkpoint whose anchor is evicted (or dropped when a commit clears a redo branch) -is pruned, and `revertTo` on it throws. +the entry they sit after, so they stay correct as eviction renumbers positions. +A checkpoint is pruned when its anchor is destroyed: the entry it sits after is +evicted or dropped by a commit that clears the redo branch. A position-0 +checkpoint is pruned by any eviction, since the undos back to it are gone. +`revertTo` on a pruned name throws. ## Non-goals diff --git a/src/core.ts b/src/core.ts index 5161aea..bde9d90 100644 --- a/src/core.ts +++ b/src/core.ts @@ -65,13 +65,10 @@ export function createHistory(options: HistoryOptions = {}): History { for (const listener of listeners) listener(meta); }; - const pruneCheckpoints = (removed: Entry[]): void => { - if (checkpoints.size === 0 || removed.length === 0) return; - const removedSet = new Set(removed); + /** Drop every checkpoint whose anchor was destroyed. */ + const pruneCheckpoints = (dead: readonly Anchor[]): void => { for (const [name, anchor] of checkpoints) { - if (anchor !== START && removedSet.has(anchor)) { - checkpoints.delete(name); - } + if (dead.includes(anchor)) checkpoints.delete(name); } }; @@ -82,10 +79,12 @@ export function createHistory(options: HistoryOptions = {}): History { } entries.push(entry); cursor += 1; - // Evict the oldest entries past the cap. + // Evict the oldest entries past the cap. Eviction also kills START: the + // undos that reached position 0 are gone — unlike a redo-branch drop, + // which leaves it intact. if (limit !== undefined && entries.length > limit) { const overflow = entries.length - limit; - pruneCheckpoints(entries.splice(0, overflow)); + pruneCheckpoints([...entries.splice(0, overflow), START]); cursor -= overflow; } }; @@ -179,6 +178,7 @@ export function createHistory(options: HistoryOptions = {}): History { if (anchor === undefined) { throw new Error(`reversible: unknown checkpoint "${name}"`); } + // Pruning keeps the map free of stale anchors, so indexOf never returns -1. const target = anchor === START ? 0 : entries.indexOf(anchor) + 1; move(target - cursor); }; diff --git a/tests/checkpoint.test.ts b/tests/checkpoint.test.ts index 00e18ef..06519be 100644 --- a/tests/checkpoint.test.ts +++ b/tests/checkpoint.test.ts @@ -89,6 +89,29 @@ describe("checkpoints", () => { expect(() => history.revertTo("first")).toThrow(); }); + test("a position-0 checkpoint is pruned when eviction makes its state unreachable", () => { + const history = createHistory({ limit: 2 }); + history.checkpoint("pristine"); // position 0, before any entry + history.commit(noop); + history.commit(noop); + history.commit(noop); // evicts entry 0 -> position 0 no longer names the pristine state + + expect(history.hasCheckpoint("pristine")).toBe(false); + expect(() => history.revertTo("pristine")).toThrow(); + }); + + test("a position-0 checkpoint survives a dropped redo branch", () => { + const history = createHistory(); + history.checkpoint("pristine"); + history.commit(noop); + history.undo(); + history.commit(noop); // drops the redo branch; position 0 is still reachable + + expect(history.hasCheckpoint("pristine")).toBe(true); + history.revertTo("pristine"); + expect(history.position).toBe(0); + }); + test("a checkpoint in a dropped redo branch is pruned", () => { const history = createHistory(); history.commit(noop); // e1