Skip to content

snapshot: ignore() silently swallows errors, leaking gitignored files into undo/revert operations #28033

Closed as not planned

Description

@Small-tailqwq

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:

  1. Send a prompt to a model that exhausts a rate-limit or quota mid-generation.
  2. Press Esc to interrupt.
  3. Switch to another model with a smaller context window, triggering context compression.
  4. Press Esc again before the compaction completes.
  5. 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 same ignore() 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

  1. Small-tailqwq commented on May 17, 2026

    @Small-tailqwq
    Author

    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 a1a31db19aac4d560bf1aa976d5e8550b4a59ce6 is still present in the snapshot git repo (not yet pruned by gc --prune=7.days):

    Snapshot Total files archive/ files (gitignored)
    Old stale (a1a31db) 5,519 3,820 ❌
    Pre-undo snapshot (49eff17) 960 0 ✅
    Current clean snapshot 891 0 ✅
    # Verify on your machine (replace <snapshot_dir>):
    git --git-dir=<snapshot_dir> ls-tree -r --name-only a1a31db19aac4d560bf1aa976d5e8550b4a59ce6 | grep "^archive/" | wc -l
    # Returns: 3820

    This proves that at some point during the session, ignore() returned an empty Set, and 3,820 gitignored files from archive/ were tracked in the snapshot tree without being dropped.

    2. Log Evidence of Frozen Snapshot

    The log file 2026-05-16T072927.log shows the same stale hash repeating 90+ times without changing:

    INFO service=snapshot hash=a1a31db19aac4d560bf1aa976d5e8550b4a59ce6 ... tracking
    INFO service=snapshot hash=a1a31db19aac4d560bf1aa976d5e8550b4a59ce6 ... tracking
    INFO service=snapshot hash=a1a31db19aac4d560bf1aa976d5e8550b4a59ce6 ... tracking
    ...
    

    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:

    1. Two messages typed that failed to send (API error)
    2. /undo × 2
    3. The UI immediately showed the diff panel with archive/ files listed (negative diff lines)

    No complex interruption scenario was needed.

    4. The diff() Leak Path

    Beyond the revert() path, the session/revert.ts flow at line 76 also contributes to the observed behavior:

    if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)

    snap.diff() calls add() internally (which may leave gitignored files in the index — impact path 1 in the issue), then runs git diff --cached with no ignore() filter on its output. This means even when revert() does not actually touch gitignored files (because patch() 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.

  2. Small-tailqwq commented on May 17, 2026

    @Small-tailqwq
    Author
    Image Image
  3. Small-tailqwq commented on May 17, 2026

    @Small-tailqwq
    Author

    Possibly related Issues: #26893

  4. 21pounder commented on May 18, 2026

    @21pounder
    Contributor

    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 empty Set — simulating what happens when git check-ignore exits with a non-0/non-1 code.

    Instrumented reproduction of impact path 2

    Steps:

    1. Let the AI create secret.txt with no .gitignore present — the file gets staged into the snapshot index via add()
    2. Added secret.txt to .gitignore
    3. Ran /undo

    The backend log confirmed ignore() was returning empty on every call:

    Image

    Before /undo, secret.txt is present alongside .gitignore:

    Image Image

    After /undo, secret.txt is deleted from disk:

    Image

    This confirms the impact path 2 chain: ignore() returns empty → secret.txt is not filtered out of the Patch → 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 of new Set():

    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 new Set(files)
    }

    I'll also add an ignore() filter to diff() output to close impact path 3.

  5. github-actions commented on Jul 18, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions