Repository navigation
Description
Summary
The ignore() helper in packages/opencode/src/snapshot/index.ts silently returns an empty Set when git check-ignore exits with any code other than 0 or 1. This causes every downstream consumer to treat all files as non-ignored, which can lead to gitignored files being written, restored, or deleted from disk during /undo and /redo operations.
Root Cause
// snapshot/index.ts:129
if (check.code !== 0 && check.code !== 1) return new Set<string>()git check-ignore uses exit code 0 (files ignored) and 1 (no files ignored) for normal operation. Any other code — e.g. 128 for a git error, which can be triggered by path-length issues on Windows, a missing .git directory, concurrent git lock contention, or a malformed .gitignore — is silently treated as "nothing is ignored". The error is discarded with no log entry, and the empty Set propagates to three consumers:
Impact path 1 — add() skips drop() (snapshot/index.ts:226-232)
const ignored = yield* ignore(all)
if (ignored.size > 0) { // ← size is 0 when ignore() fails
yield* drop(ignoredFiles) // ← never executed
}Gitignored files are not removed from the snapshot's internal git index. They get committed into the next write-tree snapshot hash.
Impact path 2 — patch() leaks files into stored patches (snapshot/index.ts:325-330)
const ignored = yield* ignore(files)
return {
hash,
files: files.filter((item) => !ignored.has(item)) // ← nothing filtered out
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}Gitignored file paths survive into the Patch object that is persisted as a session message part. When /undo later calls snap.revert(patches), the revert() function processes each file without any gitignore check:
- If the file exists at the patch's snapshot hash → the old content is silently restored.
- If the file does not exist at that hash (e.g. it was created during the step being undone) → it is deleted from disk (
fs.remove()).
Impact path 3 — diff() has no ignore filter on its output (snapshot/index.ts:478-496)
const diff = Effect.fnUntraced(function* (hash: string) {
yield* add() // ← may leave gitignored files in index (impact path 1)
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], ...)
return result.text.trim() // ← raw output, no ignore() filter
})diff() calls add() but applies no ignore() filter to its output. This result is stored as rev.diff and shown in the undo UI (revert.ts:76):
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)Even when revert() does not actually touch gitignored files (because they were filtered out of a working patch()), their paths appear in the undo diff panel. This is the secondary "UI leak" that is observable without actual filesystem damage.
Secondary structural issue — add() only processes changed/untracked files
Beyond the ignore() error case, add() only calls ignore() against files returned by git diff-files (changed) or git ls-files --others (newly untracked). Files that are already in the snapshot index and become gitignored without being modified never appear in either list, so they are never passed to ignore() and never drop()'d. They persist in the snapshot index indefinitely.
Affected versions
Confirmed in v1.15.3; the pattern exists in older versions too.
Reproduction conditions
The ignore() failure is intermittent, but has been observed reliably on Windows (including WSL) under these conditions:
- Send a prompt to a model that exhausts a rate-limit or quota mid-generation.
- Press
Escto interrupt. - Switch to another model with a smaller context window, triggering context compression.
- Press
Escagain before the compaction completes. - Issue
/undo.
The multi-interrupted session creates several patch parts across multiple assistant messages. If git check-ignore fails during any of those patch() or add() calls, gitignored files enter stored patches, and /undo then deletes or restores them. /redo brings them back via snap.restore().
Suggested fix
Replace the silent empty-Set fallback with either:
Option A — log the error, preserve safety (minimal change):
if (check.code !== 0 && check.code !== 1) {
log.warn("git check-ignore failed, treating all candidates as ignored for safety", {
exitCode: check.code,
stderr: check.stderr,
fileCount: files.length,
})
// Return ALL files as "ignored" so nothing leaks into patches or gets deleted.
return new Set(files)
}Option B — propagate the failure so the calling function (add, patch, diff) can abort cleanly instead of operating on incomplete data.
Additionally:
diff()should apply the sameignore()filter to its raw output before returning.add()should periodically audit the snapshot index against current ignore rules (not just against the delta of changed/newly-untracked files), to catch the structural case where a file becomes gitignored without being modified.
Code references
| Location | Line | Issue |
|---|---|---|
packages/opencode/src/snapshot/index.ts |
129 | ignore() swallows non-0/non-1 exit codes |
packages/opencode/src/snapshot/index.ts |
226 | add() only drops ignored files if ignore() returns non-empty set |
packages/opencode/src/snapshot/index.ts |
325 | patch() filter bypassed on ignore() failure |
packages/opencode/src/snapshot/index.ts |
481 | diff() has no ignore() filter on raw output |
packages/opencode/src/snapshot/index.ts |
680 | diffFull() filter bypassed on ignore() failure |
packages/opencode/src/session/revert.ts |
76 | rev.diff populated from unfiltered snap.diff() output |
Activity
Small-tailqwq commentedon May 17, 2026
Additional Evidence from Real Reproduction
I encountered this bug on Windows 11 (OpenCode v1.15.3) and can provide concrete proof from the snapshot git objects:
1. Stale Snapshot Tree Object Still Exists
The stale snapshot hash
a1a31db19aac4d560bf1aa976d5e8550b4a59ce6is still present in the snapshot git repo (not yet pruned bygc --prune=7.days):archive/files (gitignored)a1a31db)49eff17)This proves that at some point during the session,
ignore()returned an empty Set, and 3,820 gitignored files fromarchive/were tracked in the snapshot tree without being dropped.2. Log Evidence of Frozen Snapshot
The log file
2026-05-16T072927.logshows the same stale hash repeating 90+ times without changing:The snapshot was frozen for over 40 minutes (07:34 → 08:17), confirming that
add()was not updating the index during that period.3. Reproduction (Simpler than Described)
The trigger in my case was not rate-limits or context compression — it was simply:
/undo× 2archive/files listed (negative diff lines)No complex interruption scenario was needed.
4. The
diff()Leak PathBeyond the
revert()path, the session/revert.ts flow at line 76 also contributes to the observed behavior:snap.diff()callsadd()internally (which may leave gitignored files in the index — impact path 1 in the issue), then runsgit diff --cachedwith noignore()filter on its output. This means even whenrevert()does not actually touch gitignored files (becausepatch()filtered them out), the diff shown in the undo panel can still include gitignored file paths.This is the secondary "UI leak" — visible without filesystem damage but alarming and confusing to users.
Small-tailqwq commentedon May 17, 2026
Small-tailqwq commentedon May 17, 2026
Possibly related Issues: #26893
21pounder commentedon May 18, 2026
I read through the code and traced all three impact paths. I wanted to verify impact path 2 concretely before commenting, so I added a temporary patch to
ignore()that forces it to always return an emptySet— simulating what happens whengit check-ignoreexits with a non-0/non-1 code.Instrumented reproduction of impact path 2
Steps:
secret.txtwith no.gitignorepresent — the file gets staged into the snapshot index viaadd()secret.txtto.gitignore/undoThe backend log confirmed
ignore()was returning empty on every call:Before
/undo,secret.txtis present alongside.gitignore:After
/undo,secret.txtis deleted from disk:This confirms the impact path 2 chain:
ignore()returns empty →secret.txtis not filtered out of thePatch→revert()finds it absent from the snapshot hash →fs.remove()deletes it.Note on real-world trigger
This simulation forces the failure unconditionally. In production the trigger is a non-0/non-1 exit from
git check-ignore— exit code 128 on Windows under path-length pressure, lock contention, or a malformed.gitignore. The current code has no log entry at that point, so the failure is invisible until files start disappearing.Planned fix
I'll implement Option A. On a non-0/non-1 exit, return
new Set(files)instead ofnew Set():I'll also add an
ignore()filter todiff()output to close impact path 3.github-actions commentedon Jul 18, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.