[BUG] Desktop: unrequested background `git fetch` to origin on diff/commit refresh — untraceable by design, and no setting disables it
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
The desktop app — not the CLI — runs git fetch against origin with no user request, no permission prompt, and no record in any session transcript. It is triggered by ordinary UI work (computing a diff, listing commits, the diff prefetcher), so it needs no worktree, no PR, and no git-related prompt from the user.
This is related to #21108, but that report is about the CLI; the code path below exists only in the desktop app, so a CLI fix will not cover it. It is also the network-side analogue of #79713 (harness mutating the user's repo at session spawn with nothing in the transcript).
Where it comes from
Grepping the literal string --no-write-fetch-head:
| Binary | Hits |
|---|---|
| %APPDATA%\Claude\claude-code\2.1.221\claude.exe (desktop-managed CLI) | 0 |
| VS Code extension anthropic.claude-code-2.1.207 native binary | 0 |
| C:\Program Files\WindowsApps\Claude_1.25927.0.0_x64__pzs8sxrjxfjjc\app\resources\app.asar | 1 |
It is emitted by the desktop Electron main process, from the worktree manager's refreshSourceRef():
execFetchCapped(
['fetch', '--no-write-fetch-head', 'origin', `+refs/heads/${n}:refs/remotes/origin/${n}`],
baseRepo, kt, signal
)
and the -c prefix matches the observed command exactly:
var M = ['-c','safe.directory=*'],
N = ['-c','core.fsmonitor=false'],
Ce = ['-c','core.hooksPath=/dev/null'];
var we = [...Ce, ...M, ...N];
There are two trigger paths, and the second one is the problem:
- Worktree acquisition/creation — on a cold repo it first runs a full
fetch --prune originin the background (telemetry reasoncold_full_fetch), then narrows torefreshSourceRef. GitStatusService—fetchGitDiffUncached,fetchGitDiffStatsUncached,getGitCommits, and the diff prefetcher each dothis.config.refreshBaseRef && await this.config.refreshBaseRef(cwd, baseRef), which funnels into the samerefreshSourceRef. No worktree required.
Why this is a security issue, not a UX annoyance
- Unrequested outbound connection using the user's credentials. On SSH remotes it consumes the SSH agent / key — exactly the symptom in #21108. On VPN-gated or air-gapped remotes it is an unrequested connection attempt to a host the user did not ask to contact at that moment.
- **The CLI itself classifies
git fetchas not read-only.** The CLI bundle excludesgit fetch/git pullfrom its read-only git set, on the stated grounds that despite looking read-only they amount to arbitrary code execution. The desktop app runs, unattended and unprompted, the very command the CLI refuses to auto-approve. --no-write-fetch-headmeans it leaves no trace.FETCH_HEADis never updated, so the user cannot tell afterwards that it happened. I initially concluded my repo had never been auto-fetched becauseFETCH_HEADwas stale — that inference is wrong by construction.- It silently advances
refs/remotes/origin/<branch>, breaking baregit push --force-with-lease. The lease is taken against the remote-tracking ref, and a background fetch renews it behind the user's back. Same class of harm as #77660 ("refs/remotes/*must remain truthful"), but here it is a concrete data-loss vector.
There is currently no way to disable it
I grepped the desktop bundle for autoFetch, disableFetch, fetchDisabled, skipFetch, offlineMode, disableGit — zero hits. The only related key, useWorktree, is a per-session/scheduled-task parameter and does not affect path 2, so opting out of worktrees does not stop this.
What Should Happen?
- Add a setting that disables background remote git access, e.g.
git.autoFetch: "never" | "worktree-only" | "always", honored by both trigger paths and readable fromsettings.jsonso it can also be applied as a managed/enterprise policy. - Make it opt-in, or at minimum prompt once per repository. Contacting a remote with the user's credentials should not be a silent default — this is the same consent boundary the CLI already enforces by excluding
git fetchfrom its read-only set. - Until (1) ships, document the behavior, including that
--no-write-fetch-headmakes it untraceable and how it interacts withgit push --force-with-lease.
Error Messages/Logs
Observed process (Windows 11, desktop app is the parent process):
git.exe -c core.hooksPath=/dev/null -c safe.directory=* -c core.fsmonitor=false fetch --no-write-fetch-head origin +refs/heads/master:refs/remotes/origin/master
No corresponding entry in any session transcript. .git/FETCH_HEAD is NOT updated by this command, so the repository retains no evidence that it ran.
String search for --no-write-fetch-head:
%APPDATA%\Claude\claude-code\2.1.221\claude.exe 0 hits
.vscode\extensions\anthropic.claude-code-2.1.207-win32-x64\...\claude.exe 0 hits
C:\Program Files\WindowsApps\Claude_1.25927.0.0_x64__pzs8sxrjxfjjc\app\resources\app.asar 1 hit
Guards observed in app.asar (constant names are minifier output for build 1.25927.0.0):
Z = 1800*1e3 30-minute TTL + in-flight dedup on the worktree path
kt = 1e4 10s cap on the narrow fetch
Ot = 3e4 30s cap on the full "fetch --prune origin"
separate TTL on the diff path (telemetry reason: diff_base_ref_timeout)
failures are non-fatal: logged as "continuing with on-disk refs"
Telemetry events already emitted for this code path:
desktop_ccd_worktree_source_ref_fetch
cold_full_fetch
desktop_ccd_worktree_fetch_degraded
Steps to Reproduce
- Open a repository that has an
originremote in the desktop app's Code tab. No worktree, no PR, no git-related prompt to Claude. - Let the app render the changes/diff view or the commit list. (The diff prefetcher will also do this on its own, without interaction.)
- Observe the spawned processes. On Windows:
``powershell``
Get-CimInstance Win32_Process -Filter "Name = 'git.exe'" |
Select-Object ProcessId, ParentProcessId, CommandLine
The fetch --no-write-fetch-head origin +refs/heads/<branch>:refs/remotes/origin/<branch> command appears, parented to the desktop app.
- Check
.git/FETCH_HEAD— it is not updated, so nothing in the repo records that the fetch happened.
With an SSH remote whose key carries a passphrase, this surfaces as an unexplained passphrase/agent prompt with no user action behind it — the same symptom as #21108, but a different code path.
Note on timing: a 30-minute TTL applies, so if you have used the repo recently you may need to wait it out before the fetch fires again.
Claude Model
Other
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.221 (Claude Code)
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Other
Additional Information
- Surface: Claude Desktop Code tab, not a terminal — the "Terminal/Shell" dropdown has no matching option, hence "Other".
- Desktop app version:
1.25927.0.0(Windows 11 x64, MSIX install). - Bundled CLI:
2.1.221. VS Code extension present at2.1.207(also unaffected). - Remote: self-hosted GitLab over SSH (
git@<internal-git-host>:...). Repo has no worktrees (git worktree listshows only the main working tree). - All findings above come from grepping
app.asarof build1.25927.0.0. The minified identifiers (Z,kt,Ot,we) will change between builds; the stable anchors are the method namerefreshSourceRef, the literal flag--no-write-fetch-head, and the telemetry event names. - Related: #21108 (same symptom, CLI code path), #79713 (harness mutating the repo at session spawn with no transcript record), #77660 (truthfulness of
refs/remotes/*).
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Follow-up: still present on desktop
1.30096.1.0Retested a week later, two desktop releases on from the original report (
1.25927.0.0→1.26832.0.0→1.30096.1.0, bundled CLI now2.1.229).Unchanged, byte-for-byte: the fetch argv,
execFetchCapped()'sextraEnv, the 10s timeout, and therefreshSourceRef/refreshBaseRefcall sites. Only minified identifiers moved (kt→Y). Not repeating the code from the original post.Still no opt-out.
autoFetch,disableFetch,fetchDisabled,skipFetch,offlineMode,disableGitall return 0 hits, as dodisableBackgroundGit*variants matching the naming proposed in #86363. The complete Code-tabPreferencesset is unchanged:ccAutoArchiveOnPrClose,ccBranchPrefix,ccMaxWarmWorktrees,ccRecipients,ccRemoteControlDefaultEnabled,ccWorktreeReapAfterHours.Worth noting that this module was not untouched during the week — other methods on the same worktree-manager class were edited in
1.30096.1.0. The unrequested remote access on the diff path was simply left as-is.Independent confirmation on another platform: #86363 (Linux, labeled
area:security) captured the same behavior at runtime via/proc+ cgroup attribution, including the sameGIT_SSH_COMMAND=ssh -o BatchMode=yesthis code sets. Their passphrase-protected key pops agcr-ssh-askpassdialog on every background access; mine is managed by 1Password and pops its authorization prompt. Neither of us issued any remote git command.Still reproduces on desktop
1.34493.1.0(2026-08-21 build, CLI2.1.237). The fetch argv,execFetchCapped()'sextraEnv, and the fullGitStatusService→refreshBaseRef→refreshSourceRefchain are unchanged; all opt-out key candidates still return 0 hits.Correction to my earlier comment: the diff path does not use the 30-minute default. This build de-minified the constant — it passes
DIFF_BASE_REF_MAX_AGE_MS = 6e5, i.e. 10 minutes. So on the path that needs no worktree and no user action, an unrequested fetch can fire every 10 minutes for as long as the app is open on a repo.Still reproduces on desktop
1.40609.0.0(2026-08-29 build, CLI2.1.247) — five desktop releases since this was filed. The fetch argv,execFetchCapped()'sextraEnv, andDIFF_BASE_REF_MAX_AGE_MS(6e5, 10 minutes) are unchanged, and every opt-out key candidate still returns 0 hits.This build did change the diff path — by investing in it, not by gating it:
refreshBaseRefon the diff-stats path moved off the awaited path intorefreshBaseRefBehindStats(), which fires it in the background with an in-flight dedup set. Previously a memo miss meant the stats Promise resolved only after the network round-trip returned (up to the 10s cap); now the stats return immediately and the fetch happens behind them. Same fetch, same frequency — just no longer visible as a delay.refreshSourceRefnow returns a status (ok/joined/throttled/failed/skipped_ref) so callers can tell whether a fetch actually happened;refreshBaseRefcollapses that too === 'ok' || o === 'joined'.onBaseRefMovedcallback invalidates and recomputes the diff, then notifies every session reading that cwd, when the background fetch advances the base ref.In other words, the unrequested fetch is now the trigger for automatic diff refresh — actively developed, non-blocking, deduplicated — and still with no way for the user to decline it.