Pre-submit Checks
- I have searched Warp bugs and there are no duplicates
- I have searched Warp known issues page and my issue is not there
- I have an issue with AI and have included the debugging ID (Optional, but helps expedite the AI quality fix). Debugging ID instructions
- I have technical issue and have included the logs (optional, but helps expedite the bug fix). Log instructions
Describe the bug
When Codebase Context is on and a repo is indexed, Warp opens a handle on every file that changes in that repo, including gitignored paths, within about 10 µs to 10 ms of the change. While that handle is open, any other program that replaces the file with MoveFileExW(…, MOVEFILE_REPLACE_EXISTING) gets ERROR_ACCESS_DENIED (os error 5). That is the common "write a temp file, then rename it over the original" save pattern.
I encountered this as intermittent "Access is denied" failures in a Rust project's test suite, which writes and replaces many small files under target/. In a tight loop it reproduces almost every time (196 of 200 trials). With Codebase Context off it does not reproduce at all, and neither VS Code nor RustRover watching the same tree causes it (for comparison).
To reproduce
- Windows 11. In Warp, turn Settings → Code → Codebase Context on (I also have auto-indexing on).
- Open a Warp tab in the repo and leave it idle. Wait until Settings → Code shows the repo as indexed. The file watcher is registered only when an index build loads the repo's gitignores (
GitignoresUpdated→watch_path). Turning Codebase Context off drops all indices (reset_codebase_indexing), so after turning it back on the repo has to be re-indexed before this reproduces. - From another terminal, run the script below against a new folder inside the repo. A gitignored folder still reproduces:
pwsh -NoProfile -File .\warp-replace-repro.ps1 -Path <repo>\target\warp-repro - Turn Codebase Context off, keep Warp open, and run it again (or point
-Pathoutside any repo).
My results with this script:
- Codebase Context on:
176 of 200 trials got ERROR_ACCESS_DENIED from MoveFileExW(REPLACE_EXISTING) in C:\repos\foo\target\warp-repro-on (1.9 s) - Codebase Context off:
0 of 200 trials got ERROR_ACCESS_DENIED from MoveFileExW(REPLACE_EXISTING) in C:\repos\foo\target\warp-repro-off (3.2 s)
Measurements from an equivalent Node script (same algorithm, 200 trials each, same repo, gitignored folder):
| Condition | Trials with Access denied |
|---|---|
| Warp closed | 0 / 200 |
| Warp open, idle tab in the repo, Codebase Context on | 196 / 200 |
| Warp closed again | 0 / 200 |
| Warp open, idle tab in the repo, Codebase Context off | 0 / 200 |
| Warp open, folder outside any repo | 0 / 200 |
| Warp closed, VS Code open on the repo | 0 / 200 (visible folder: 1 / 200) |
| Warp closed, RustRover open on the repo | 0 / 200 |
warp-replace-repro.ps1 (Windows PowerShell 5.1 or PowerShell 7, no elevation)
param(
[Parameter(Mandatory = $true)][string]$Path, # a new folder; the script creates it
[int]$Trials = 200,
[int]$Attempts = 50,
[switch]$Keep # keep the folder instead of deleting it
)
$ErrorActionPreference = 'Stop'
# Warp's handle lives only microseconds, so the hot loop is compiled C#; a plain PowerShell loop
# reproduces far less often.
Add-Type -TypeDefinition @'
using System.IO;
using System.Runtime.InteropServices;
public static class WarpReplaceRepro {
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool MoveFileExW(string existingFileName, string newFileName, uint flags);
const uint MOVEFILE_REPLACE_EXISTING = 1;
// Returns the number of trials in which a replace was refused with ERROR_ACCESS_DENIED.
public static int Run(string dir, int trials, int attempts) {
byte[] payload = new byte[1300];
int denied = 0;
for (int t = 0; t < trials; t++) {
string destination = Path.Combine(dir, "d-" + t + ".json");
File.WriteAllBytes(destination, payload); // create, write and close
for (int a = 0; a < attempts; a++) {
string temp = Path.Combine(dir, ".t-" + t + "-" + a);
File.WriteAllBytes(temp, payload);
if (!MoveFileExW(temp, destination, MOVEFILE_REPLACE_EXISTING)) {
int err = Marshal.GetLastWin32Error();
if (err != 5) throw new IOException("MoveFileExW failed with Win32 error " + err + " (expected only 5)");
denied++; // another process had the destination open
break;
}
}
}
return denied;
}
}
'@
if (Test-Path -LiteralPath $Path) { throw "Pass a folder that does not exist yet: $Path" }
New-Item -ItemType Directory -Path $Path | Out-Null
$watch = [Diagnostics.Stopwatch]::StartNew()
try {
$denied = [WarpReplaceRepro]::Run((Resolve-Path -LiteralPath $Path).ProviderPath, $Trials, $Attempts)
}
finally {
if (-not $Keep) { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Continue }
}
'{0} of {1} trials got ERROR_ACCESS_DENIED from MoveFileExW(REPLACE_EXISTING) in {2} ({3:N1} s)' -f $denied, $Trials, $Path, $watch.Elapsed.TotalSecondsExpected behavior
Warp's watcher should not hold handles on files in the watched tree, least of all gitignored ones, so other programs can replace them. Other tools watching the same tree (VS Code, RustRover) don't cause this.
Screenshots, videos, and logs
No response
Operating system (OS)
Windows
Operating system and version
10.0.26300
Shell Version
PowerShell 7.6.7
Current Warp version
v0.2026.10.14.08.27.stable_03
Regression
No, this bug or issue has existed throughout my experience using Warp
Recent working Warp date
No response
Additional context
More Specific OS Details: Windows 11 Pro 10.0.26300. The repo is on a ReFS Dev Drive; NTFS is not tested.
I had an agent examine the source, at a36b76b. It proposed the following as the cause:
crates/ai/src/index/full_source_code_embedding/manager.rs#L860-L870:watch_pathbuilds the filter!is_git_internal_path(path) && !matches_gitignores(path, path.is_dir(), …).path.is_dir()is evaluated before the ignore match, so every non-.gitpath is stat'd, whether ignored or not..gitignoreand.warpindexingignorecan't prevent it.warpdotdev/notifynotify/src/windows.rs#L376callsshould_emit_event(&path)synchronously in theReadDirectoryChangesWcompletion handler, for every raw event, before any debouncing.- On Windows,
Path::is_dir()isfs::metadata, which opens a handle. rust-lang/rust#130169 proposes a handle-free alternative (GetFileInformationByName). - Turning Codebase indexing off goes through
reset_codebase_indexing(app/src/ai/persisted_workspace.rs#L645-L648) and removes the problem: 196/200 → 0/200.
Possible fix: stat only when the answer depends on it.
is_dirmatters only when a directory-only pattern (build/, or a negation like!*/) is the match for the path itself; parent directories are always matched as directories (ignore0.4.24,gitignore.rs#L234-L262). So evaluatematches_gitignoreswithis_dir = falseand withis_dir = true:- If they agree, use that answer with no I/O. That covers everything under
target/, and every path no directory-only pattern matches, which includes ordinary source files. - If they disagree, fall back to
path.is_dir()as today.
- If they agree, use that answer with no I/O. That covers everything under
- This gives today's answer for every path, so it needs no downstream change and stays correct for both roles the closure serves (
should_watch_directoryandshould_emit_event). Nothing after the filter re-checks gitignores, so simply emitting the ambiguous paths without a stat would index files excluded by a negation such asparentdir/*+!parentdir/*/, the case inentry_tests.rs. - Regression test: extract the predicate, as
repo_watch_filteralready is inrepo_metadata/src/entry.rs, with an injectedis_dirprobe. Assert that the probe is never called fortarget/**or for ordinary source paths, that it is called for abuild/-matched leaf, and that every answer matches the old predicate, including theentry_tests.rsnegation case.
Related, not duplicates:
- #11464 adopted the current
should_watch_directory/should_emit_eventfilter API (from warpdotdev/notify#3). Before it the predicate made three handle-opening calls per event (exists(),canonicalize,is_dir()); now it makes one. #15240 changed only the type ofwatch_path'sgitignoresargument. Theis_dir()call is still there ata36b76b. - #13153 (closed): the same Windows root cause.
ReadDirectoryChangesWdeliverstarget/**events because descend pruning is inotify-only; the symptom there was background thread-pool saturation. - #8409: CPU while a busy session runs. A comment there notes that git worktrees nested under the repo get watched too. Open PR #15249 addresses Windows directory re-walks.
- #13323: the file-tree path ignores nested
.gitignorefiles. - The repo watcher (
repository.rs:568) and the file tree (local_model.rs:1535,1541,1818) also stat changed paths before checking ignores, but only after a 0.5–1.25 s debounce. So turning Codebase indexing off removes the fast holder, not every open: Warp still used about 27 s of CPU per test round with it off, against about 38 s with it on. The impact is lower, and the same "check the patterns first" idea would apply there.
If this gets marked ready-to-implement, I'm happy to send a PR for the indexer predicate.
Does this block you from using Warp daily?
No
Is this an issue only in Warp?
Yes, I confirmed that this only happens in Warp, not other terminals.
Warp Internal (ignore): linear-label:b9d78064-c89e-4973-b153-5178a31ee54e
No response
@EthanHipps
Here's what I found while triaging this issue:
Thanks for the detailed repro. This looks like a Windows-specific Codebase Context watcher bug with strong reproduction evidence; turning Codebase Context off appears to be a valid temporary workaround based on the report.
Maintainer details
I concluded that windows Codebase Context file watching appears to open transient handles on changed files in indexed repositories, causing atomic replace operations to fail with access denied..
Triage summary
This is a well-specified Windows bug report for Codebase Context/codebase indexing. The reported symptom is that, after a repository has been indexed, Warp's watcher briefly opens handles on changed files in the repo, including gitignored paths such as
target/, causing other programs' atomic replace operations to fail withERROR_ACCESS_DENIED/ os error 5.Evidence supports high reproducibility: the report includes a standalone PowerShell/C# repro, 176/200 and 196/200 failure rates with Codebase Context on, and 0/200 controls when Warp is closed, Codebase Context is off, or the folder is outside an indexed repo.
The current code supports the likely root cause hypothesis.
CodebaseIndexManager::watch_pathregisters a recursive watcher afterGitignoresUpdatedand its filter callspath.is_dir()beforematches_gitignores(...). On Windows that metadata probe can open a transient handle for every raw watcher path before ignored paths are discarded. The shared watcher passes this filter into the notify debouncer registration path, so this can happen before Warp's higher-level debouncing.reset_codebase_indexingalso explains why disabling Codebase Context removes the fast repro path.Relevant areas for maintainers:
crates/ai/src/index/full_source_code_embedding/manager.rsfor the Codebase Context watcher filter.crates/watcher/src/lib.rsfor watcher registration throughWatchFilter.app/src/ai/persisted_workspace.rsfor Codebase Context enable/disable behavior.crates/repo_metadata/src/entry.rsfor existing repo watcher ignore-filter patterns and related tests.I did not find enough duplicate evidence to mark this as a duplicate. The closest open candidates are broader or different watcher/indexing reports, not the same Windows file-handle/atomic-replace failure mode.
This is my automated analysis and may be incorrect. A maintainer will verify the details.
Powered by Oz