Repository navigation
[BUG] [CRITICAL] Snapshot .nothrow() causes silent data loss — /undo and /redo revert files to stale content (Windows) #10589
Description
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:
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
.quiet()
.cwd(Instance.directory)
.nothrow() // ← CRITICAL: exit code 128 is silently ignoredThe 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 STALESince 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:
POST /:sessionID/abort→SessionPrompt.cancel()(stops AI processing)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 addstarts 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
/undois 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
- Open a Git Bash terminal on Windows
- Navigate to a project directory with some files
- 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 > nultypically 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
- Note: This may or may not create the file depending on your shell. In Git Bash,
- Start OpenCode in that directory
- Send a message to the AI assistant and let it modify some files
- Use
/undo - Observe: Files are reverted to their state from before the
nulfile 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
- Same fix in
patch()anddiff()— checkgit addexit code - Staleness detection in
revert()— compare snapshot hash against a known-good baseline - User-visible warning — surface the error in the TUI (e.g., "Snapshot system degraded: git add failed")
Note on prior fix attempts
- PR fix: handle special characters in paths and git snapshot reading logic(#9804) #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 fix(snapshot): handle git add failures with --ignore-errors fallback #9715 proposes an
--ignore-errorsfallback 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
- Check for reserved Windows filenames in your project:
ls -la nul con aux prn 2>/dev/null - Delete them using Git Bash:
rm -f nul con aux prn - Add Windows filenames to project .gitignore (hypothetical solution; user still must test)
- 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
Activity
github-actions commentedon Jan 25, 2026
This issue might be a duplicate of existing issues. Please check:
Feel free to ignore if none of these address your specific case.
github-actions commentedon Mar 27, 2026
To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one.