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 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:
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 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
- 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 > 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
- 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
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
- Same fix in
patch() and diff() — check git add exit 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
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
Description
[BUG] Snapshot
.nothrow()causes silent data loss —/undoand/redorevert files to stale content (Windows)Summary
When
git add .fails insideSnapshot.track(), the.nothrow()call silently swallows the error.git write-treethen returns a stale tree hash from the old index. Every subsequent snapshot returns this same stale hash indefinitely. When the user invokes/undoor/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-errorsfallback)Root Cause Chain
1. Trigger: Reserved filename poisons
git addA file named
nulwas created in the project directory. On Windows,nulis a reserved device name (along withcon,aux,prn,com1–com9,lpt1–lpt9). Git cannot add files with these names:The
nulfile was created by a bash shell error — when a Windows command likedir /bis executed in Git Bash, the error output can be redirected to create a file literally namednul(rather than the NUL device). This is an easy accidental creation path.2.
.nothrow()swallows the failureIn
snapshot/index.ts, line 68:The
git addfails with exit code 128, but.nothrow()ensures no error is thrown. No exit code check follows.3.
write-treereturns stale hashSince
git add .failed, the index was never updated.write-treeserializes the old index, returning the same hash as the last successful snapshot. In our case, hash6176986df4d15c5eff575618d0f6a13a14d72a3ccontained content from January 11 — 14 days before the reversion was triggered.4.
/undoor/redoapplies stale contentThe 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()usesgit checkout {staleHash} -- {file}to restore files. Since the hash points to 14-day-old content, files are overwritten with ancient versions.Similarly,
/redocallsSessionRevert.unrevert()→Snapshot.restore()which doesgit 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
log.info("tracking", { hash })that shows the same hash repeating)git addstarts failing, EVERY snapshot is stale. The poison persists across sessions until the problematic file is removed./undois the trigger, and the stale snapshot mechanism is invisible.Reproduction Steps
echo "test" > nul(or any Windows reserved name)echo > nultypically writes to NUL device. Use a tool that bypasses Windows device name resolution, or copy a file and rename it.python -c "open('nul', 'w').write('test')"from Git Bash/undonulfile was created, not from the beginning of the current sessionLog Evidence
When snapshots are working correctly, each
track()call produces a unique hash:When poisoned, every call returns the same hash:
Affected Code
File:
packages/opencode/src/snapshot/index.tsThe
.nothrow()pattern appears in 4 functions, all vulnerable:track()git add .patch()git add .diff()git add .init(inside track)git initThe
revert()andrestore()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
Additional hardening
patch()anddiff()— checkgit addexit coderevert()— compare snapshot hash against a known-good baselineNote on prior fix attempts
a96f3d1). The revert suggests the fix had unintended side effects. A commenter noted the PR lacked unit tests.--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 handleundefinedreturns fromtrack()in most paths.Environment
nul(Windows reserved device name)Workaround
ls -la nul con aux prn 2>/dev/nullrm -f nul con aux prnThere 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