Skip to content

[BUG] [CRITICAL] Snapshot .nothrow() causes silent data loss — /undo and /redo revert files to stale content (Windows) #10589

@NamedIdentity

Description

@NamedIdentity

Description

[BUG] Snapshot .nothrow() causes silent data loss — /undo and /redo revert files to stale content (Windows)

Summary

When git add . fails inside Snapshot.track(), the .nothrow() call silently swallows the error. git write-tree then returns a stale tree hash from the old index. Every subsequent snapshot returns this same stale hash indefinitely. When the user invokes /undo or /redo, files are reverted to the stale snapshot content — potentially days or weeks old — with no warning or error message.

This caused 14 days of lost work on curated project state files in our case.

Affected versions: v1.1.34, v1.1.36 (latest), likely all versions
Platform: Windows 10 (trigger is Windows-specific, but the .nothrow() vulnerability is cross-platform)
Related: #10034 (same root cause, different symptom — tmp_pack disk leak), PR #9715 (proposed fix with --ignore-errors fallback)


Root Cause Chain

1. Trigger: Reserved filename poisons git add

A file named nul was created in the project directory. On Windows, nul is a reserved device name (along with con, aux, prn, com1com9, lpt1lpt9). Git cannot add files with these names:

$ git add .
error: invalid path 'nul'
error: unable to add 'nul' to index
# Exit code: 128 (fatal)

The nul file was created by a bash shell error — when a Windows command like dir /b is executed in Git Bash, the error output can be redirected to create a file literally named nul (rather than the NUL device). This is an easy accidental creation path.

2. .nothrow() swallows the failure

In snapshot/index.ts, line 68:

await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
  .quiet()
  .cwd(Instance.directory)
  .nothrow()  // ← CRITICAL: exit code 128 is silently ignored

The git add fails with exit code 128, but .nothrow() ensures no error is thrown. No exit code check follows.

3. write-tree returns stale hash

const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
  .quiet()
  .cwd(Instance.directory)
  .nothrow()
  .text()
return hash.trim()  // Returns whatever is in the index — which is STALE

Since git add . failed, the index was never updated. write-tree serializes the old index, returning the same hash as the last successful snapshot. In our case, hash 6176986df4d15c5eff575618d0f6a13a14d72a3c contained content from January 11 — 14 days before the reversion was triggered.

4. /undo or /redo applies stale content

The user invokes /undo, which calls:

  1. POST /:sessionID/abortSessionPrompt.cancel() (stops AI processing)
  2. POST /:sessionID/revertSessionRevert.revert()Snapshot.revert(patches) (selectively reverts files)

Snapshot.revert() uses git checkout {staleHash} -- {file} to restore files. Since the hash points to 14-day-old content, files are overwritten with ancient versions.

Similarly, /redo calls SessionRevert.unrevert()Snapshot.restore() which does git read-tree {staleHash} && git checkout-index -a -f, overwriting all tracked files.

There is no validation that the snapshot hash is fresh. No warning is emitted. The user's files are silently destroyed.


Impact

  • Data loss: Curated project files (documentation, state files, configuration) overwritten with content from weeks prior
  • Silent: No error in UI, no warning in logs (only a debug-level log.info("tracking", { hash }) that shows the same hash repeating)
  • Persistent: Once git add starts failing, EVERY snapshot is stale. The poison persists across sessions until the problematic file is removed.
  • Difficult to diagnose: The reversion appears to happen "randomly" because the user doesn't realize /undo is the trigger, and the stale snapshot mechanism is invisible.
  • Recovery requires git expertise: Files can only be recovered from git commit history, which requires knowing which commit had the correct content.

Reproduction Steps

  1. Open a Git Bash terminal on Windows
  2. Navigate to a project directory with some files
  3. Create a reserved filename: echo "test" > nul (or any Windows reserved name)
    • Note: This may or may not create the file depending on your shell. In Git Bash, echo > nul typically writes to NUL device. Use a tool that bypasses Windows device name resolution, or copy a file and rename it.
    • Alternative reliable method: python -c "open('nul', 'w').write('test')" from Git Bash
  4. Start OpenCode in that directory
  5. Send a message to the AI assistant and let it modify some files
  6. Use /undo
  7. Observe: Files are reverted to their state from before the nul file was created, not from the beginning of the current session

Log Evidence

When snapshots are working correctly, each track() call produces a unique hash:

INFO service=snapshot hash=a1b2c3d4... cwd=/project git=...
INFO service=snapshot hash=e5f6a7b8... cwd=/project git=...  (different)

When poisoned, every call returns the same hash:

INFO service=snapshot hash=6176986df4d1... cwd=/project git=...
INFO service=snapshot hash=6176986df4d1... cwd=/project git=...  (same!)
INFO service=snapshot hash=6176986df4d1... cwd=/project git=...  (same!)

Affected Code

File: packages/opencode/src/snapshot/index.ts

The .nothrow() pattern appears in 4 functions, all vulnerable:

Function Line Git Command Impact
track() 68 git add . Stale snapshot hashes → data loss on revert
patch() 86 git add . Incorrect diff calculation
diff() 164 git add . Incorrect diff display
init (inside track) 63 git init Silent init failure

The revert() and restore() functions (lines 111-160) trust that snapshot hashes are valid. They have no staleness detection.


Proposed Fix

Minimum viable fix: Check exit code, return undefined on failure

export async function track() {
  if (Instance.project.vcs !== "git") return
  const cfg = await Config.get()
  if (cfg.snapshot === false) return
  const git = gitdir()
  if (await fs.mkdir(git, { recursive: true })) {
    await $`git init`.env({
      ...process.env,
      GIT_DIR: git,
      GIT_WORK_TREE: Instance.worktree,
    }).quiet().nothrow()
    await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
    log.info("initialized")
  }

  // FIX: Check exit code instead of blindly continuing
  const addResult = await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
    .quiet()
    .cwd(Instance.directory)
    .nothrow()
  
  if (addResult.exitCode !== 0) {
    log.warn("git add failed - snapshot may be stale", {
      exitCode: addResult.exitCode,
      stderr: addResult.stderr.toString().slice(0, 500),
    })
    // Option A: Return undefined (callers skip snapshot)
    return undefined
    // Option B (PR #9715 approach): Retry with --ignore-errors
    // const retry = await $`git ... add --ignore-errors .`...
  }

  const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
    .quiet()
    .cwd(Instance.directory)
    .nothrow()
    .text()
  log.info("tracking", { hash, cwd: Instance.directory, git })
  return hash.trim()
}

Additional hardening

  1. Same fix in patch() and diff() — check git add exit code
  2. Staleness detection in revert() — compare snapshot hash against a known-good baseline
  3. User-visible warning — surface the error in the TUI (e.g., "Snapshot system degraded: git add failed")

Note on prior fix attempts

The minimum viable fix (check exit code, return undefined) is the safest approach. It keeps .nothrow() but adds validation. Callers already handle undefined returns from track() in most paths.


Environment

  • OS: Windows 10
  • OpenCode version: v1.1.34 (also confirmed in v1.1.36 source)
  • Shell: Git Bash (mintty)
  • Git version: 2.47.1.windows.1
  • Trigger file: nul (Windows reserved device name)

Workaround

  1. Check for reserved Windows filenames in your project: ls -la nul con aux prn 2>/dev/null
  2. Delete them using Git Bash: rm -f nul con aux prn
  3. Add Windows filenames to project .gitignore (hypothetical solution; user still must test)
  4. Verify snapshots are working: check dev.log for varying snapshot hashes after each AI interaction

There is no automated workaround within OpenCode itself. Users must manually identify and remove the problematic file.

Plugins

oh-my-opencode v3.0.0

OpenCode version

1.1.34

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

Windows 10

Terminal

Windows Terminal

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingperfIndicates a performance issue or need for optimizationwindows

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