Skip to content

Session snapshot "restore/revert" bulk-overwrites the whole worktree with a stale baseline tree that is never rotated #49732

Closed as not planned

Description

@kobevv5

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

Activity

  1. Dante-dan commented on Sep 18, 2026

    @Dante-dan
    Contributor

    Thanks for the detailed trace. I’m following the capture and restore paths now. I’ll keep the original baseline only while expanding the same staged undo, make new step captures reflect the current worktree, and add a bounded preflight before any whole-tree restore. I’ll verify consecutive captures plus multi-step undo/unrevert behavior before opening a fix.

  2. lasertiger commented on Oct 6, 2026

    @lasertiger

    See #53560 as well

  3. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions