Skip to content

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

Closed as not planned

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.ts — track() 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.ts — cleanup() 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.ts — computeDiff() 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"

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Mar 23, 2026
  2. ForbiddenEra commented on Mar 27, 2026

    @ForbiddenEra
    Author

    Again, I'm really not 100% sure about the root cause or anything, I sent claude 4.6 down the rabbit hole and everything. I've seen it happen sometimes and not others too.. like I have one project open, where I'm more just using the model to do some file management (myrient, iykyk) but it's definitely created some scripts, I see no changes or anything, I'm like 99.9% sure it hasn't created a git repo, though it doesn't say "create a git repo" in the ui..

    but I have another project using the same model where I had it start writing some stuff (one prompt so far) and opened it back up and "create a git repo" was a button, so I did, and now I see changes in that one (so far, who knows if/when it'll break?)

    this does feel like basic functionality though, not sure why it seems to be broken; I definitely feel like it miiiiiiight be related to the model but maybe not, I haven't done enough testing to be sure but like, I have 7 oc projects open and like 1 works, I think, I just checked another, it shows a change to one file but not others.

    :'(

  3. ForbiddenEra commented on Mar 29, 2026

    @ForbiddenEra
    Author

    so I just tried with both ChatGPT and Opus, if I created a git repo FIRST before opening in OC, no issue?!

    closing a project, deleting the project dir, re-creating and opening just opens the old session tho (with same issue)

  4. ForbiddenEra commented on Mar 29, 2026

    @ForbiddenEra
    Author

    so I just tried with both ChatGPT and Opus, if I created a git repo FIRST before opening in OC, no issue?!

    closing a project, deleting the project dir, re-creating and opening just opens the old session tho (with same issue)

    also if this is the case then the above research is either hallucinogenic-ish or a separate bug; I will research further and update..?

  5. ForbiddenEra commented on Mar 29, 2026

    @ForbiddenEra
    Author

    Update: Different Root Cause Identified

    After deeper investigation, I've found that the issue I'm experiencing (and likely many of the "empty review panel" reports) is actually a different and more fundamental bug than the git gc --prune=7.days issue described in the original report. The gc/prune issue is likely real and separate, but what I'm actually hitting is simpler and more common.

    The Actual Root Cause: Snapshots Disabled for Non-Git Directories

    File: packages/opencode/src/snapshot/index.ts — enabled():

    const enabled = Effect.fnUntraced(function* () {
      if (state.vcs !== "git") return false
      return (yield* config.get()).snapshot !== false
    })

    When OpenCode opens a directory that has no .git directory, fromDirectory() in project.ts sets vcs: undefined. The snapshot system checks state.vcs !== "git" and returns false — snapshots are completely disabled. No tree hashes are ever recorded in step parts. computeDiff() finds no from/to hashes → empty review panel.

    Evidence from my database: Sessions for directories opened without git have step parts with NO "snapshot" field:

    {"type":"step-start"}
    {"reason":"tool-calls","type":"step-finish",...}

    Compare to sessions where .git existed when the project was opened:

    {"snapshot":"4b825dc642cb6eb9a060e54bf8d69288fbee4904","type":"step-start"}
    {"reason":"tool-calls","snapshot":"da4cd800edf4abd7c6ebdb3870325f4ca5d8460b","type":"step-finish",...}

    The Global Project Problem Makes This Worse

    When no .git is found, the project gets assigned to ProjectID.global (literal string "global"). The upsert in fromDirectory() overwrites the global project row every time ANY non-git directory is opened, including the vcs and worktree fields:

    .onConflictDoUpdate({
      target: ProjectTable.id,
      set: {
        worktree: result.worktree,
        vcs: result.vcs ?? null,   // <-- overwrites for ALL global projects
        ...
      },
    })

    This creates cascading issues:

    1. The "Create a Git Repo" button is shown/hidden based on global state, not per-directory state. The button appears when project.vcs is falsy (session.tsx line ~640). Since all non-git projects share the global project row, the button visibility depends on which directory was opened LAST — if the last directory happened to have .git, vcs="git" and the button is hidden for ALL non-git directories.

    2. Snapshot repos can point to wrong directories. The snapshot path uses Hash.fast(ctx.worktree), but the global project's worktree keeps getting overwritten. In my case, the snapshot/global/ directory contains:

      • The old git repo structure (from sez-bitest, opened months ago)
      • 23c1e088.../ → worktree /home/.../foapp (opened later with .git)
      • 5d27e0d2.../ → worktree /home/.../octest (opened later with .git)
      • No subdirectory for flashover at all (opened first without .git)
    3. Even git init without commits doesn't fully fix it. If .git exists but there are no commits, git rev-list --max-parents=0 HEAD returns nothing, so the project STILL falls back to ProjectID.global. You get a proper snapshot subdir and vcs="git", but still share the global project row. Only after making at least one commit does the project get its own ID.

    Reproduction Steps (Simplified)

    1. Create a new empty directory (no git)
    2. Open it in OpenCode (opencode web, navigate to the directory)
    3. Have the assistant create some files
    4. Check the Review panel → empty, no changes shown
    5. The "Create a Git Repo" button may or may not appear depending on whether OTHER projects with git were opened in the same OpenCode instance

    What Works vs What Doesn't

    Scenario Review Works? Why
    git init + at least 1 commit BEFORE opening in OC YES Gets own project ID, own snapshot repo, vcs="git"
    git init (no commit) BEFORE opening in OC Partially Stays global but gets own snapshot subdir, vcs="git"
    No git at all before opening in OC NO vcs=null, snapshots entirely disabled
    git init mid-session (by assistant or user) NO Project already cached with vcs=null, no re-detection

    Suggested Fixes

    1. Remove the VCS gate from enabled(). The snapshot system creates its own independent git repo in ~/.local/share/opencode/snapshot/ — there's no technical reason to require the user's project to also have git. Change enabled() to just check the config flag.

    2. Don't share ProjectID.global across unrelated directories. Each non-git directory should get a unique, stable project ID (e.g., hash the absolute path). The current design means opening any non-git directory overwrites the global project's worktree/vcs, breaking all other non-git projects.

    3. Watch for .git creation and re-detect. If the user or assistant runs git init mid-session, OpenCode should detect this and call Instance.reload() to re-initialize the project.

    4. Fix the "Create a Git Repo" button visibility. It should be based on the current session's directory having a .git, not on the shared global project's vcs field.

    5. Expose a "Re-detect Project" action in the UI (or auto-trigger it) so users can recover from this state without restarting.

    Relationship to Original Report

    The git gc --prune=7.days issue from the original report is likely a separate, real bug that affects long-running sessions where snapshots ARE working. It would cause review to break after ~7 days when the initial tree hashes get garbage collected. But many of the "empty review panel" reports (including mine) are caused by this more fundamental issue: snapshots never being created in the first place because no git repo existed when the project was first opened.

    Versions Tested

    • OpenCode 1.3.5 (current)
    • Confirmed the behavior on two separate models (claude-opus-4.6 and chatgpt) — the bug is not model-dependent
  6. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  7. github-actions commented on Jul 3, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

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