Desktop Material

Multiple accounts and repository identity

Desktop Material can retain multiple GitHub.com identities, multiple accounts on one Enterprise host, and GitLab or Bitbucket identities. Account metadata is stored separately from credentials; tokens remain in the operating-system credential vault.

Repository Settings exposes an exact account binding for provider operations such as fetch, push, pull requests, issues, Actions, and Releases. The Git tab independently chooses global or repository-local user.name and user.email, so authentication identity and commit authorship are explicit instead of being silently coupled.

Rich account picker and switcher

The account picker and navigation-rail account switcher show a friendly name, @login · host, provider, plan, and a visible display email when one exists. Private email metadata is omitted, and self-hosted accounts are labelled accurately instead of being presented as GitHub. The active identity is marked without exposing its credential. Both surfaces search by name, login, host, provider, plan, or visible email; plain text remains the default, with fuzzy, substring, and bounded safe-regex modes available through the anchored shared Regex Builder.

Arrow keys, Home/End, Enter, and Escape work from the field and rows. Invalid patterns report an inline error without discarding the list or activating the first row, and a no-match result leaves Add another account available. Search metadata excludes tokens, private email values, local paths, and other credential material. Rows use the stable endpoint#id identity, so a login rename cannot make the picker select a different credential. The switcher exposes a labelled listbox and active descendant so assistive technology can follow the highlighted row.

Organization Git operations

HTTPS Git operations carry the repository's stable endpoint#id account key through normal and scheduled fetch, pull, push, post-push refresh, refspec fetch, and remote-HEAD discovery. The key selects a credential-vault identity; it is never a token, command-line argument, or environment variable. This keeps a personal GitHub.com account from being selected merely because it was signed in before the organization-authorized account.

Background remote-HEAD lookup first validates the local symbolic ref and reuses it only when it points below refs/remotes/<remote>/ and its target exists. That avoids an online git remote set-head -a scan during every scheduled refresh, which can otherwise scale with the server's complete ref inventory. A missing, empty, malformed, dangling, or other-remote background ref performs exactly one discovery with the same selected account. A user-initiated fetch always refreshes the default with a five-second hard bound, so a generic host's rename is detected even while the old branch still exists.

An explicit binding is authoritative. If that account is no longer available, the operation fails with account recovery rather than silently using another same-host account. For a legacy unbound repository, Desktop Material checks same-origin signed-in identities against the remote and prefers an account with push or admin permission before a read-only account. A successful lookup is then saved as the repository binding, so subsequent operations remain stable.

Changing the binding refreshes repository metadata and permissions under the new identity. Saving an unrelated repository setting does not accidentally bind the first same-host account. SAML reauthorization recognizes GitHub's supported organization-quote formats, including repository-not-found ambiguity when GitHub intentionally hides a private organization repository.

When a saved binding is missing, stale, lacks permission, or needs organization SSO, the operation stops with the appropriate account-management or sign-in recovery. A unique valid account may be suggested; ambiguous same-host matches require a labelled user choice and never replace a still-valid binding.

The accounts store caps and validates persisted metadata, de-duplicates stable account keys, and never writes tokens to its metadata file or application log. Repository bindings use stable account keys rather than array positions.

Sign-in persistence is concurrency-safe. The periodic account refresh re-reads the account list after its API calls return instead of writing back the snapshot it started with, so an account signed in while a refresh is running is never erased and a token re-authorized for wider scopes (for example the package-registry grant) is never rolled back to the narrower one. Saving metadata merges with the shared saved list rather than overwriting it, so a window holding a stale snapshot cannot sign out an account another window added; identities this store signed out, or rewrote through the Enterprise endpoint migration, are recorded so the merge neither resurrects nor duplicates them. That endpoint migration also moves the token to the new endpoint key, because credentials are addressed by endpoint.

Active account switching

The navigation rail avatar opens the account switcher. Choosing any signed-in account makes it the active identity used by the rail indicator and the positional account fallbacks, including when the chosen account is on GitHub Enterprise and another GitHub.com account is also signed in. The selected account remains first in the in-memory and persisted account order; the other accounts retain the normal GitHub.com-before-Enterprise grouping within their remaining positions. Reopening the switcher therefore shows the same account as active after the app has saved or reloaded its metadata.

Choosing the already-active row only closes the switcher. Choosing another row closes it and promotes the stable endpoint#id identity without changing its credential. When a repository is selected, a deliberate promotion also updates that selected repository's binding when the account is a GitHub identity on the same API endpoint. This makes the visible action affect the next fetch, push, pull request, and other authenticated operation instead of leaving an older auto-binding in place. Other repositories retain their bindings, and accounts on a different host or provider never cross the boundary. Tokens remain exclusively in the operating-system credential vault. If the account cannot be found in the current signed-in list, promotion is a no-op and the existing active identity is preserved.

Settings → Accounts uses the same global active identity rather than a provider-local first-row convention. Every provider section compares its card with the single accounts[0] account, so a cross-provider list has exactly one Active chip and every other signed-in account has a working Make active action. The click updates the account order through the same store path as the rail switcher. The selected same-host repository is deliberately rebound by that action; every other repository binding remains authoritative and is not silently replaced by a global reorder.

Credential failures are surfaced, never swallowed. An account whose token cannot be written is not added and the failure names the login. An account whose stored token is missing or unreadable at startup is reported as needing a new sign-in instead of being loaded with an empty token — the state that previously made the app look signed in while every API call failed and the sign-in prompt kept returning — and its saved metadata is preserved so a transient credential-store failure does not permanently sign the account out. A re-authorization that arrives under a renamed login clears the superseded credential entry rather than leaving a live token behind.

Invalidated tokens on a shared host

The GitHub API client raises an invalidated-token signal when a request returns 401 with the X-GitHub-Request-Id header and no two-factor challenge. The signal carries the endpoint and the exact token the failing request was made with; it carries no login or account id, because the API client is constructed from an endpoint and a credential.

The token is the identity. Several accounts can be signed in on one host, so the endpoint alone does not say whose credential died — resolving by endpoint position picks whichever account sorts first, which used to mean an invalidated token belonging to any other account signed nobody out at all and the app kept using the dead credential and re-prompting. getAccountForEndpointAndToken matches the signed-in account on that endpoint whose stored credential is the failing one, so exactly that account is signed out and every other account on the host stays signed in. The comparison uses the in-memory account list, which already carries each token rehydrated from the credential vault, so no extra vault read is needed.

When no signed-in account on the endpoint holds the token any more, nobody is signed out. That is the correct outcome for a credential already replaced by a re-authorization or belonging to an account that is already signed out; acting on a stale token would sign out a healthy account. The case is logged as a warning.

The sign-out notice names the affected login and host, states whether other accounts on the same host are still signed in, and offers to repeat that sign-in. It is available in English, Hong Kong Cantonese, and bilingual mode (accounts.invalidatedToken*) and stays plain and factual at every funny level, as authentication errors must.

The main-process same-origin filter keeps the initial origin only for the life of one Electron request. It deletes the record on completion and on failure/cancellation, so repeated failed requests cannot retain origin entries for the app lifetime. Redirected requests still lose authorization-like headers whenever their current origin differs from the initial origin.

Verification includes accounts-store-test.ts, accounts-store-persistence-test.ts, get-account-for-repository-test.ts, repositories-store-test.ts, push-authenticated-git-test.ts, pull-authenticated-git-test.ts, fetch-authenticated-git-test.ts, organization-repository-auth-wiring-test.ts, saml-reauth-error-test.ts, same-origin-filter-test.ts, and the provider-triage UI/store suites. Cross-provider active-card behavior is covered by ui/accounts-test.tsx, while the rail click path is covered by ui/account-switcher-test.tsx; the focused account/store/routing/UI run for the 2026-08-05 correction passed 39/39. The follow-up selected-repository binding coverage is in active-account-promotion-test.ts and the helper contract in get-account-for-repository-test.ts; the combined focused run for commit 5235d4fff9efbd605a5083a5dec58970d8797863 passed 131/131. Targeted ESLint, TypeScript no-emit, Prettier, and git diff --check also pass; the hidden-desktop artifact and capture remain pending while the required Lowlevel endpoint is occupied by another checkout.