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

Open
opened 2026-02-16 18:07:37 -05:00 by yindo · 1 comment
Owner

Originally created by @NamedIdentity on GitHub (Jan 25, 2026).

Originally assigned to: @rekram1-node on GitHub.

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

  • PR #9807 attempted to fix special character path handling in snapshot. It was merged Jan 21, 2026 and reverted the next day (commit a96f3d1). The revert suggests the fix had unintended side effects. A commenter noted the PR lacked unit tests.
  • PR #9715 proposes an --ignore-errors fallback approach, which is more conservative and would work for our case. It remains open and unmerged.

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

Originally created by @NamedIdentity on GitHub (Jan 25, 2026). Originally assigned to: @rekram1-node on GitHub. ### 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`, `com1`–`com9`, `lpt1`–`lpt9`). 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: ```typescript 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 ```typescript 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/abort` → `SessionPrompt.cancel()` (stops AI processing) 2. `POST /:sessionID/revert` → `SessionRevert.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 ```typescript 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 - **PR #9807** attempted to fix special character path handling in snapshot. It was merged Jan 21, 2026 and **reverted the next day** (commit `a96f3d1`). The revert suggests the fix had unintended side effects. A commenter noted the PR lacked unit tests. - **PR #9715** proposes an `--ignore-errors` fallback approach, which is more conservative and would work for our case. It remains open and unmerged. 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
yindo added the windowsbugperf labels 2026-02-16 18:07:37 -05:00
Author
Owner

@github-actions[bot] commented on GitHub (Jan 25, 2026):

This issue might be a duplicate of existing issues. Please check:

  • #10034: [ROOT CAUSE ANALYSIS] tmp_pack Leak - Complete Code Analysis & Fix Recommendations - While the root cause is different (.nothrow() in snapshot tracking causing stale hashes vs. untracked tmp_pack files), both stem from the same vulnerability: git add . failures silently poisoning the snapshot system
  • #10210: Undo/revert fails for files with non-ASCII filenames on Windows - Related to revert() functionality being affected by git operations failing without proper error handling
  • #9715: fix(snapshot): handle git add failures with --ignore-errors fallback - Directly addresses the git add failure issue mentioned in the bug report

Feel free to ignore if none of these address your specific case.

@github-actions[bot] commented on GitHub (Jan 25, 2026): This issue might be a duplicate of existing issues. Please check: - #10034: [ROOT CAUSE ANALYSIS] tmp_pack Leak - Complete Code Analysis & Fix Recommendations - While the root cause is different (.nothrow() in snapshot tracking causing stale hashes vs. untracked tmp_pack files), both stem from the same vulnerability: git add . failures silently poisoning the snapshot system - #10210: Undo/revert fails for files with non-ASCII filenames on Windows - Related to revert() functionality being affected by git operations failing without proper error handling - #9715: fix(snapshot): handle git add failures with --ignore-errors fallback - Directly addresses the git add failure issue mentioned in the bug report Feel free to ignore if none of these address your specific case.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#7570