Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 0 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 8 additions & 8 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entry>(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);
}
};

Expand All @@ -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;
}
};
Expand Down Expand Up @@ -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);
};
Expand Down
23 changes: 23 additions & 0 deletions tests/checkpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down