anomalyco / anomalyco/opencode
Session snapshot "restore/revert" bulk-overwrites the whole worktree with a stale baseline tree that is never rotated
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
Description
Description
Summary
The session snapshot subsystem pins a single project-level baseline tree (a git TREE object id) on first creation and never rotates it. Every later session's step.started/revert.snapshot reuses the same tree id. A restore/revert then runs a whole-tree checkout (git read-tree + git checkout-index --all --force) with no diff-size guard, reverting the entire worktree to the state of that stale baseline — bulk-overwriting tracked files with old content and resurrecting deleted files. Bookkeeping lives in a hidden shadow repo (~/.local/share/opencode/snapshot///), so the real repo's HEAD / reflog / index stay untouched, making the event look like a silent "workspace rollback".
Evidence (DB, read-only)
All from ~/.local/share/opencode/opencode.db (SQLite, 867MB) + the shadow git repo ~/.local/share/opencode/snapshot/e242e0621b65be4ad71f837f17c59b8baae12a77//.
Across ALL step-start/step-finish part JSON in 25 days, the snapshot field contains exactly 2 distinct TREE ids:
ff71af… (first seen 2026-08-21 21:12, last seen 2026-08-22 18:07)
9660d465… (first seen 2026-08-24 22:11, last seen 2026-09-18 17:03 — 100% of snapshots for 25 days)
One session had a non-empty revert record: session.revert = { messageID: "msg_…", snapshot: "9660d465…", diff: "" } — empty diff = whole-tree rollback, no file-level plan.
The shadow repo index (written today) git diff --cached <9660d465 tree> = empty → index tree == the 8-24 baseline. git diff --cached = 334 files touched / 68,439 deletions → index is the old slim worktree, not current state.
The shadow repo: worktree = /Users/…/codex-workplace (points at the live worktree), objects/info/alternates → real repo .git/objects, HEAD unborn master, empty refs, no reflog. git status --short shows the whole tree staged (A/AM).
Timeline: shadow repo created 08-17; lost-found/ created 08-24 13:20 (= first incident day; fsck ran); baseline switched from ff71af to 9660d465 at 08-24 22:11 (shadow repo re-created via git.repo.create which clones the main repo's index into the shadow index). Incidents observed ~08-24, 08-26, 09-11, 09-18 all restored files to the 08-24 snapshot state. Real repo git log/reflog show no reset/restore — HEAD intact, deleted files resurrected as untracked.
Evidence (source, engine bundle in Desktop app.asar)
Snapshot.capture → Git.tree.capture → per-scope Git.index.refresh (a real git add --all --sparse --pathspec-from-file=- + git rm --cached for ignored/oversized) then write-tree. So capture() is supposed to produce a fresh tree reflecting the current worktree each call.
Defect 1 — baseline never recaptured. SessionRevert.stage: original = session.revert?.snapshot ? reuseExistingTree : snapshot.capture(). Once a revert.snapshot is stored, every later stage/restore reuses the OLD tree; capture is short-circuited forever.
Defect 2 — no guard on whole-tree restore. The restore/checkout path ends in git read-tree + checkout-index --all --force (unconditional full-tree overwrite). No diff-size / file-count check.
Defect 3 — content-addressing self-stabilizing loop. Tree ids are content-addressed. After a restore writes the 8-24 content back into the worktree, the next capture produces the same tree id (identical content ⇒ identical SHA), so the recorded baseline never visibly changes — masking the bug for weeks.
Workaround for users: set "snapshots": false in opencode.json (this is the exact config key checked by the engine: config.latest(entries, "snapshots") !== false).
Suggested fix
Never reuse a stored revert baseline for a new session/step — always call capture() at session/step start (or at minimum cap the reuse to the same session).
Add a guard before any whole-tree checkout-index --all --force: compute the diff size between target tree and current worktree and abort (with a clear error) when it exceeds a sane threshold (e.g. >500 files or >50k lines), requiring explicit confirmation.
Derive the session-revert target from the latest captured tree, and prefer per-file git.tree.restore over whole-tree checkout for session-level reverts.
Consider validating that target tree is reachable/plausible relative to current state before writing.
Versions: Desktop App 1.18.21 / engine bundle app.asar node-CMcqrhBZ.js; TUI 1.18.30.
Plugins
No response
OpenCode version
1.18.21 (1.18.21)
Steps to reproduce
No response
Screenshot and/or share link
No response
Operating System
No response
Terminal
No response
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with SessionRevert.stage and the snapshot.capture, Git.tree.capture, and Git.index.refresh entry points in the engine bundle app.asar (node-CMcqrhBZ.js). Trace how the stored session.revert snapshot reaches the restore/checkout path and how the snapshots config key is checked. Done means new steps capture a current baseline and large whole-tree restores are rejected or require explicit confirmation, with regression coverage added where the project’s tests permit.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, typescript
- Domain
- devtools
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100