Git credentials

Обновлено 28 августа 2026 г. · написано рядом с кодом, который описывает
Это руководство ещё не переведено — показана английская версия.

Helmry does not store your git credentials, and will not ask for them. That is a decision, not a gap. This page says why, what the app does instead, and the one-time setup each platform needs.

Read this before debugging a failed push, and before configuring anything on Windows + WSL. The recipe that looks obvious there — pointing a distro’s git at the Windows Git Credential Manager — is measured broken on this machine and turns every push into a 60-second hang. It is written up under One-time setup; the short version is use an ssh key inside the distro. Two people (and one agent) have now rediscovered that the hard way because nothing linked here.

Why not

Three reasons, and the third is the decisive one.

  1. A git credential is wider than Helmry’s trust boundary. An account here is already a key to the whole machine (see CLAUDE.md), but a personal access token is a key to repositories, organisations and CI beyond it. Silently widening the first into the second is not ours to do.
  2. A second store drifts. A rotated token, a revoked grant, a scope that was never requested — all of it would diverge from the one the user’s own terminal already uses.
  3. Delegating through git’s config keeps the secret out of the agent’s environment. With a credential.helper configured, git push from an agent’s own Bash works and the token never appears in that process’s environment: git talks to the helper itself. Handing the credential to the child through GIT_ASKPASS would put it one env away from a transcript — the same reasoning that keeps Atlassian’s OAuth grant out of the agent’s MCP config (docs/tasks.md §4).

Every serious GUI git client works this way (VS Code, Fork); the ones that keep their own token store are the ones users fight with over re-authentication.

What Helmry does instead

  • Never blocks on a prompt — in both halves. Every git subprocess runs with GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never (server/src/workspace/git-env.ts), and so does every agent (agent/claude-cli/claude-cli.ts). The first stops git asking; without it a push on a machine with no helper spent its whole 60-second timeout waiting for a username nobody could type, then reported the timeout rather than the cause. The second stops the helper git spawned, which the first cannot touch — and that half was missing.

    Measured 2026-08-16, Windows git 2.52.0 + Git Credential Manager 2.6.1, the default credential.helper = manager, nothing cached for the host, asked from a child with no tty:

    outcome
    GIT_TERMINAL_PROMPT=0 alone never returns, and leaves a 93.6 MB git-credential-manager.exe orphaned per attempt
    + GCM_INTERACTIVE=never 206 ms, exit 128, terminal prompts disabled

    This retires a claim that used to be on this page: that a helper’s GUI window is “the sanctioned way a first credential is obtained”. It does not appear for a process with no terminal — not even one running inside the user’s own interactive desktop session. It hangs instead. So on Windows there is no such thing as an agent obtaining a first credential, and the honest instruction is the one under One-time setup: authenticate once yourself, in your own terminal.

  • Kills the helper, not just git. A timeout signals the whole process group / tree (server/src/workspace/proc-kill.ts), because child.kill() and execFile’s own timeout reach the direct child only — which is how the 93.6 MB orphans above survived. GCM_INTERACTIVE=never stops that helper hanging in the first place; this bounds a helper we have not met.

  • Asks git, rather than guessing. git credential fill with prompting disabled answers “is there a credential for this host” without a network call, and without the probe being able to raise a dialog (credential.interactive=false). That answer is what the git window’s remote line states, and the git window’s Check access button re-asks it on demand and reports how long it took.

  • Distinguishes “no credential” from “the helper is not answering.” Those used to be one answer (unknown), rendered as “credentials unknown until you push” — an invitation to do the one thing that cannot resolve it. A helper that stalls does not fail, it hangs, and it will hang identically every time until it is replaced. It is now its own state (stalled) with its own message, and helmry doctor reports it separately from a missing credential.

  • Refuses before the network, not by the network. A push or fetch on a machine with no credential (or a stalled helper) used to spend its full 60-second budget and then report the timeout rather than the cause. Both facts are decidable locally in about five seconds, so preflightRemote now decides them first and the operation fails immediately with a name.

  • For ssh, checks the endpoint instead — and exactly one fact about it. Keys stay the ssh agent’s business, so ssh is never gated on credentials. What it IS gated on (workspace/ssh-preflight.ts) is the one impossibility that is cheaply decidable: the endpoint accepts a TCP connection and never sends its SSH banner — a real SSH server announces itself in its first packet. Measured 2026-08-21 on this machine: a poisoned Windows DNS cache resolved bitbucket.org to 10.0.0.1, something there accepted :22 and stayed mute, and every push hung ~135 s before reporting a timeout; the probe names it in 4 s, with the resolved address in the message (a private address for a remote host is called out as the DNS-interception signature it is; the cure was Clear-DnsClientCache). Everything uncertain passes untouched: the probe asks ssh -G for the effective endpoint (so a config-level HostName/Port rewrite is probed where ssh would really go) and skips itself entirely under ProxyCommand/ProxyJump, GIT_SSH/GIT_SSH_COMMAND or core.sshCommand, when ssh is missing, when the name does not resolve, and for endpoints that are unreachable or refuse/close the connection — git fails fast there with words classifyGitFailure already names. Addresses are probed in parallel on one deadline, because the measured incident’s shape was unreachable IPv6 beside a mute IPv4 and a sequential probe would spend its whole budget on the dead family.

  • Decides where a push goes — never push.default. A bare git push leaves the destination ref to the machine’s config, and a branch cut from origin/master inherits origin/master as its upstream (branch.autoSetupMerge is true by default, and Helmry’s own worktree add -b … <base> and switch -c … <base> were two of the places doing the cutting). Measured on git 2.50.1, with DYNQRA-6563 tracking origin/master:

    push.default git push
    unset (simple, the default since git 2.0) refuses: The upstream branch … does not match
    upstream / tracking DYNQRA-6563 -> master — the feature branch is published as master

    The second row is a silent, outward-facing write to the branch everyone else builds on, from a button whose confirmation named a repo, a branch and a remote URL but never a ref. So the destination is computed by server/src/workspace/push-plan.ts, spelled as an explicit refspec (<branch>:refs/heads/<branch>), and shown in the dialog before the user agrees to it. A push from Helmry always writes the branch’s own name: when the upstream names another branch it is reported (retargets), not followed, and the push re-points the branch as it goes. Branches are also no longer born mis-tracked — both creation sites pass --no-track unless the new branch has the start point’s own name (git-switch.inheritsTracking). Landing on the repository’s default branch is not refused (a person on master pushing origin/master is ordinary) but it is named: targetProtected makes the dialog a red one that Enter will not confirm.

    Deliberately not done: forcing push.default into the agents’ own environment via GIT_CONFIG_KEY_*. Those override the user’s repo and global config for every command an agent runs, and for somebody on push.default = current that turns a working git push into an error. The agent’s own pushes are a separate question from this button’s, and one that needs its own measurement first.

  • Names the failure. could not read Username for 'https://github.com' becomes “No git credentials on this machine” plus what to do; the raw text stays available. See classifyGitFailure for the full list of named causes.

  • Answers per environment. A Windows hub and a WSL satellite have separate credential state, so the question is asked on the server that would run the push — not on whichever one the browser is talking to. This is the same rule spend follows: the fact belongs to the machine that does the work.

  • Reports it up front. helmry doctor prints a line per repository: git auth · AgentHub — github.com · https · NO credentials here.

One-time setup

Pick the one for the machine (or the distro) that runs the agents.

Where What to run
Windows Push once from your own terminal. Git for Windows ships Git Credential Manager, which handles GitHub/GitLab/Bitbucket OAuth in a window — but only where a person is present. It caches the result in Windows Credential Manager, and every agent works non-interactively from then on. An agent cannot do this first step for you (see the measurement above).
WSL (repos inside a distro) Use an ssh key inside the distro (ssh-keygen -t ed25519, then add the public key to the hosting account — the git window links to that page). The obvious alternative, pointing the distro’s git at the Windows helper, is measured broken here — see the warning below before trying it.
macOS git config --global credential.helper osxkeychain (ships with git).
Linux desktop git config --global credential.helper libsecret (needs git-credential-libsecret and a running secret service).
Headless Linux Either git config --global credential.helper store — a plaintext file at ~/.git-credentials, mode 600 — or an ssh remote with a deploy key. Choose deliberately; Helmry will not write that file for you.

Then confirm — and do confirm, because a helper that hangs is worse than no helper at all:

printf 'protocol=https\nhost=github.com\n\n' | GIT_TERMINAL_PROMPT=0 git -c credential.interactive=false credential fill

It must answer in under a second, either with username=/password= lines or with a non-zero exit. GIT_TERMINAL_PROMPT=0 cannot stop a helper from blocking, so one that neither answers nor exits turns every push into a 60-second timeout with nothing in the log.

The WSL → Git Credential Manager trap

Pointing a distro’s git at /mnt/c/Program Files/Git/mingw64/bin/git-credential-manager.exe does not give the distro Windows’ credentials. Measured on this machine with the store helper disabled so GCM is the only one asked: without the flag it hangs (killed at 25s); with GCM_INTERACTIVE=never it returns in 213 ms and declines. Helmry sets that flag now, so the bridge is no longer a hang — it is a fast, useless failure, reported as missing rather than stalled.

This corrects what this page said before, which was that the flag did not help and the helper never returned “even with GCM_INTERACTIVE=never”. The likely reason that measurement said so: a variable set inside a distro does not reach a Windows process unless it is named in WSLENV, so the flag was never seen by GCM. nonInteractiveCredentialEnv appends it to WSLENV for exactly this reason, and git-env.test.ts pins that.

The recommendation is unchanged, because a fast failure is still a failure: use an ssh key inside the distro, or the plaintext store helper. What has changed is the cost of getting it wrong — a misconfigured bridge now wastes 213 ms instead of every push’s full timeout.

After a working setup: helmry doctor shows credentials ready for that repository, and the git window’s remote line agrees — or press Check access in the git window, which re-runs the probe and reports the answer and how long it took. A helper that says yes after several seconds is the one that will make every push feel broken.

ssh instead

Nothing to configure in Helmry: keys belong to the OS and its agent. Two things to know — a server launched from a GUI (the desktop app, a shortcut) may not inherit SSH_AUTH_SOCK, which doctor’s git environment line reports; and an ssh remote answers credentials … decided at push time, because the credential helper knows nothing about keys and claiming otherwise would send you to fix the wrong thing.

origin’s host decides whether Helmry can offer an “open a pull request” link after a push, and the answer is deliberately narrow: github.com, gitlab.com and bitbucket.org only (shared/git-forge.ts). Everything else — self-hosted GitLab, Gitea, a bare path on a server — gets no link, because a self-hosted host is indistinguishable from any other by URL and a guessed /-/merge_requests/new is a 404 with your branch name in it. Authentication, the credential probe and push/fetch/pull are host-agnostic and work everywhere; only the browser link is withheld.

No forge API is ever called and no forge token is ever held. These are URLs opened in a browser you are already signed into — which is what keeps three services’ worth of convenience free of three services’ worth of OAuth obligations.

What is deliberately not built

Helmry as a credential helper — storing a token in SecretStore and handing it to git through a helper process, or minting one ourselves through a GitHub/GitLab/Bitbucket OAuth app. Re-examined when the WSL-GCM trap above turned out to hit this product’s primary platform, and rejected again — but one of the original reasons does not survive the re-examination and should not be repeated:

  • “it makes the token reachable by any process Helmry launches, including an agent with a shell” — this is true of every helper. An agent can run git credential fill itself and print the result, whether the helper is ours, store, or the OS keychain. Our own store would not create that exposure and does not close it; the argument was overweighted.
  • The reason that does hold: minting tokens makes us the issuing party for repo-scoped credentials across three vendors, each with its own developer terms and its own revocation surface — to solve a problem an ssh key solves with no secret on our side at all. Plus the original drift objection (a second store diverges from the one the user’s terminal uses), which stands unchanged.

Revisit trigger: a user on a headless box where ssh is blocked by policy and no OS helper is available. Not before, and not on a repeat of the same question without that new fact.

A GIT_ASKPASS shim that explains itself — pointing an agent’s git at a script which supplies no credential and instead prints “Helmry: no git credential is configured for this environment; run helmry doctor”. It would hold the opposite of a secret, and it fires exactly in the failure case (askpass is consulted only after every helper has declined). Spiked and it works on POSIX — git prints the script’s stderr first, ahead of its own message:

Helmry: no git credential is configured for this environment. …
error: unable to read askpass response from '/tmp/askpass.sh'
fatal: could not read Username for 'https://github.com': terminal prompts disabled

Not shipped, for one reason: GIT_ASKPASS takes a bare command path with no arguments, and how Git for Windows executes a shebang script through it is not something this spike could establish from Linux. Shipping a launch mechanism whose Windows behaviour is unverified is the mistake that cost a release once already (spawnHiddenDetached, CLAUDE.md). The value it adds over what now exists — GIT_TERMINAL_PROMPT=0 giving a searchable message, plus this page being linked from CLAUDE.md and the README — is one sentence. Revisit trigger: a Windows machine to verify it on, or a report of an agent looping on credential failures despite the current message.