Skip to content

Review panel shows no diffs: snapshot tree objects garbage collected by git gc --prune=7.days #18734

Description

@ForbiddenEra

Note from the reporter: This is an issue I've experienced quite a bit - I haven't personally investigated it but Opus4.6 did a pretty deep dive on it; essentially what happens is at some point in the session the review panel stops showing updates - I still see all changes in the session history, but the review and changes panels stop showing updates. Full details from the agent are below - I have experienced the issue several times in several sessions over several opencode versions using several different models and providers. I truly hope the information below is as accurate as possible, I'm unfortunately unfamiliar with the opencode codebase and otherwise I wouldn't be able to provide as much detail or research as the model has; even if the research is off, the description of the issue and when/how/where it appears should 100% be correct; hope you don't hate me for submitting extra detail in a report from a model

Versions affected

  • Current version: 1.3.0 (updated during session)
  • Session originally created on: 1.2.26
  • Also reproduced on: 1.2.21 (separate project, claude-opus-4.5, 4,591 steps — same failure)
  • Previously used versions with the issue: 1.1.28, 1.1.33, 1.1.34, 1.1.42, 1.2.21, 1.2.26, 1.2.27, 1.3.0

Description

The review panel shows no diffs (empty) for sessions that have been active for more than ~7 days, or for sessions with high step counts. The session history panel still shows individual changes correctly — the issue is specifically with the aggregated review/changes view.

Root cause

snapshot/index.ts uses git write-tree to capture state but never creates commit objects, leaving all tree objects unreachable in git's object model. The cleanup() function runs git gc --prune=7.days hourly, which prunes these unreachable trees. When summary.ts's computeDiff() later tries to diff the first step-start hash against the last step-finish hash, the from hash no longer exists in the object store, git diff-tree fails with fatal: bad object, and the error is silently swallowed — producing an empty diff.

Steps to Reproduce

  1. Start a session in a git-tracked project
  2. Make file changes across multiple assistant turns (generating step-start/step-finish parts with snapshot hashes)
  3. Either:
    • Continue the session for 7+ days, OR
    • Generate hundreds of steps (Claude models via github-copilot provider produce 3-15x more steps per turn than other models due to parallel tool calls)
  4. Wait for cleanup() to run git gc --prune=7.days (runs hourly with 1-minute initial delay)
  5. Open the review panel

Expected: Review panel shows diffs of all changes made during the session
Actual: Review panel is empty — no diffs shown. Session history still shows individual changes.

Detailed Root Cause Analysis

The bug chain

Step 1: snapshot/index.tstrack() creates unreachable trees

const track = Effect.fnUntraced(function* () {
  // ...
  yield* add()  // git add .
  const result = yield* git(args(["write-tree"]), { cwd: state.directory })
  return result.text.trim()  // Returns a TREE hash — no commit is ever created
})

git write-tree creates a tree object from the current index. This tree is not referenced by any commit, tag, or ref, making it an unreachable object that is eligible for garbage collection.

Step 2: processor.ts — stores hashes in step parts

On start-step: calls Snapshot.track(), stores tree hash in step-start part.
On finish-step: calls Snapshot.track() again, stores tree hash in step-finish part.

Step 3: snapshot/index.tscleanup() prunes unreachable objects

const cleanup = Effect.fnUntraced(function* () {
  yield* git(args(["gc", "--prune=7.days"]), { cwd: state.directory })
})

Runs every hour. --prune=7.days destroys any unreachable object older than 7 days. Since the tree objects from write-tree are never anchored by commits, they are always unreachable.

Step 4: summary.tscomputeDiff() fails silently

export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
  let from: string | undefined  // earliest step-start snapshot hash
  let to: string | undefined    // latest step-finish snapshot hash
  // ... scans all messages for first step-start and last step-finish ...
  if (from && to) return Snapshot.diffFull(from, to)
  return []
}

When diffFull(from, to) calls git diff-tree with a pruned from hash, git returns fatal: bad object. The error is silently swallowed (likely via .nothrow()), producing an empty result.

Evidence from database forensics

In a 6-day session (session ses_30601aff5ffe0sU89p1Q7fZvjy) with 1,200 steps:

Metric Value
Distinct tree hashes in DB 185
Still alive in object store 179
Garbage collected 6
First step-start hash 9841247c... (DEAD — created Mar 17)
Last step-finish hash 106a1529... (alive — created Mar 23)
computeDiff() result Empty (fatal: bad object on from)

The 6 dead hashes are all from the first day of the session (Mar 17) — exactly matching the 7-day prune window.

A second session on a different project (ses_32a2d666, claude-opus-4.5, v1.2.21, 4,591 steps, "PREFLIGHT_OK") shows the identical failure pattern: first hash gone, summary_files=0.

Per-step snapshots DO work

Individual step pairs correctly capture changes. For example, when editing faq.md:

  • step-start at 07:06:04 → hash d4333c...
  • step-finish at 07:06:11 → hash 857b11... (different — change captured!)
  • patch part correctly lists faq.md in changed files

The per-step mechanism works. The problem is only with the session-level computeDiff() which needs the very first hash from the session, and that's the one most likely to be gc'd.

Why this disproportionately affects Claude/Opus models

Claude models produce dramatically more steps per session than other models:

Model Provider Steps Review works?
claude-opus-4.5 github-copilot 4,591 No
claude-opus-4.6 github-copilot 1,200 No
minimax-m2.5-free opencode 316 Yes
glm-4.7-free opencode 60–130 Yes

Each step calls track()git add . && write-tree, creating more unreachable objects. More steps = more object churn = higher chance that git gc --auto triggers sooner (git auto-gc triggers based on loose object count). Combined with longer session lifetimes typical of complex multi-day work, these sessions reliably hit the prune window.

Related Issues

Issue Status Relevance
#15977 Closed (not planned) Directly identifies that write-tree creates unreachable tree objects
#12719 Open Same symptom (stale hash), different trigger (git add failure)
#10034 Open Root cause analysis noting .nothrow() silences errors
#17397 Open (has PR #17423) Notes prune=7.days hardcoding
#13065 Open Same symptom (empty review panel), undiagnosed
#10716 Open session_diff/*.json all empty

Proposed Fixes

Fix 1: Anchor trees with commits (recommended)

After write-tree, create a commit object to make the tree reachable:

const track = Effect.fnUntraced(function* () {
  if (!(yield* enabled())) return
  yield* init()
  yield* add()
  const treeResult = yield* git(args(["write-tree"]), { cwd: state.directory })
  const hash = treeResult.text.trim()

  // Anchor the tree with a commit to prevent gc
  const parentRef = yield* git(
    args(["rev-parse", "--verify", "refs/snapshots/latest"]),
    { cwd: state.directory }
  ).pipe(Effect.option)
  
  const commitArgs = parentRef._tag === "Some"
    ? ["commit-tree", hash, "-p", parentRef.value.text.trim(), "-m", `snapshot`]
    : ["commit-tree", hash, "-m", `snapshot`]
  
  const commitResult = yield* git(args(commitArgs), { cwd: state.directory })
  yield* git(
    args(["update-ref", "refs/snapshots/latest", commitResult.text.trim()]),
    { cwd: state.directory }
  )

  return hash  // Still return tree hash — git diff-tree works with both tree and commit hashes
})

Fix 2: Disable gc or use --prune=never

Simplest change — modify cleanup():

yield* git(args(["gc", "--prune=never"]), { cwd: state.directory })

This prevents pruning at the cost of unbounded disk growth in the snapshot repo.

Fix 3: Pin individual tree hashes with refs

yield* git(args(["update-ref", `refs/snapshots/t-${hash.slice(0, 12)}`, hash]), { cwd: state.directory })

Lighter than full commits but accumulates refs.

User Workaround

Starting a new session resets the baseline. The new session's first step-start hash will be fresh and won't be gc'd for at least 7 days.

Alternatively, users can manually anchor trees:

SNAP_DIR=~/.local/share/opencode/snapshot/<project_id>
TREE=$(git --git-dir="$SNAP_DIR" --work-tree="$(pwd)" write-tree)
COMMIT=$(git --git-dir="$SNAP_DIR" commit-tree "$TREE" -m "anchor")
git --git-dir="$SNAP_DIR" update-ref refs/snapshots/anchor "$COMMIT"

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions