feat(git,branches,ui): push or publish any branch to its own remote
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
This change enables pushing or publishing any local branch to its own configured remote, not just origin. In the branch switcher, branches tracked on a fork's remote (like upstream) will be pushed to their respective remotes, and on publishing an untracked branch, users can select from all available remotes. Additionally, form field accessibility has been improved so all fields are now programmatically associated with their labels for better screen reader support.
src-tauri/src/git/remote.rs to resolve and target the correct remote when pushing a branch, using the branch's upstream remote rather than defaulting to origin.src-tauri/src/mcp_server/write_git.rs) to accept and handle an optional remote argument, with tests for wire format and argument resolution.src/lib/git/api.ts and src/lib/git/queries.ts to pass an explicit remote to the push and mutation layers.src/features/repository/BranchSwitcher.tsx to:upstreamRemoteOf helper.README.md and src/features/help/content.ts to clarify remote-aware push and publish behaviors.src/components/form/fields.tsx to bind field labels via htmlFor/id.src/features/branch-rules/BranchRulesDialog.tsx, src/features/history/HistoryDialogs.tsx, src/features/issues/CreateJiraIssueDialog.tsx, src/features/issues/RepoJiraDialog.tsx, src/features/pulls/CreatePrDialog.tsx, src/features/repo-settings/SecretsSection.tsx, src/features/repository/BaseBranchCombobox.tsx, src/features/repository/BranchMergePickerDialog.tsx, src/features/repository/CreateBranchDialog.tsx, src/features/repository/RebaseOntoDialog.tsx, src/features/actions/RunWorkflowDialog.tsx, src/features/automations/LifecycleEditor.tsx, src/features/compare/CompareBranchCombobox.tsx, src/features/settings/mcp/McpServerDialog.tsx.changelog.d/changed-push-any-remote.md and changelog.d/fixed-form-label-associations.md documenting both features.src-tauri/src/git/remote.rs and src-tauri/src/mcp_server/write_git.rs to ensure correct remote resolution, explicit remote arguments, and validation of fallbacks and wire formats.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
31348a0View logs
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo security issues found.
The only new sink introduced is the
remoteparameter flowing intogit push(both the Tauri command and the MCPpushtool). I traced it and confirmed it is safe:git_push_core(src-tauri/src/git/remote.rs), a caller-suppliedremoteis passed throughvalidate_ref_name(rejects empty, leading-, and* ? [ : \ space/control chars) and checked for exact membership in the repo's actual configured remotes (git_remotes→git remote). A URL, flag, or arbitrary transport string cannot satisfy the exact-match existence check, so argument/URL/refspec injection is not reachable.push <remote>argument (build_push_args), never interpolated into a refspec, and the credential config is resolved for that same validated remote.ensure_not_flag(r, "remote")on top, andremoterequires an explicitbranch(theNonebranch rejects it early).htmlForassociations plus remote selection sourced from backend-providedremotes.data— no untrusted input reaching a sink, and client-side anyway.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🧭 Context for reviewers — deliberate decisions on this diff (two disjoint packages in one PR: multi-remote push + form-label a11y; dispositions pre-recorded so rounds can ground against them)
Multi-remote push:
resolve_push_targetfeeds both the argv ANDcredential_config_for_remote). Authenticating against origin while pushing to a fork'supstreamwould be wrong and would undermine the [#74] chain-severing;credential_config_for_remoteis genuinely remote-parameterized (resolves THAT remote's URL/host) and origin-cases are byte-identical. The HEAD path (branch: None) keeps the pre-existing origin scoping — a known, pre-existing latent mismatch for a non-origin-tracked HEAD, mitigated by the ambient-fallback retry, deliberately not widened here.push {branch}; the new default is the honest semantics.push <remote>argv position, never interpolated into a refspec; it passesvalidate_ref_name(blocklist rejects:, so URL-shaped strings can't even parse) AND a pre-mutation existence check againstgit remoteoutput — the primary guard.pushgains optionalremote(single word ⇒ no serde rename needed; defaulted None ⇒ zero behavior change);force_pushremains HEAD-only.Form-label a11y sweep:
TextField/TextareaField/MarkdownFielduseuseId+htmlFor,CheckboxFieldwraps natively, dozens of call sites passhtmlFor. The fixed gaps:SelectField(once, in the primitive — covers every call site), both branch comboboxes (triggerIdprop /aria-label), and ~12 ad-hoc bare<Label>sites (htmlFor+id, orrole="group"+aria-labelledbyfor segmented/popover-trigger fields whose primitives don't take a single htmlFor). Vendored src/components/ui/** untouched — the primitives were verified to FORWARDid(no silent no-op associations). Zero visual change (attributes only).aria-label="Compare with branch"(not "Base branch") on the compare picker is deliberate — the Compare tab's frame is "Compare {current} with…"; "Base branch" would misdescribe the control to AT users.<label>around Base UI'sCheckbox(role=checkbox button, not a native input) associates for AT; a set of group-headings over already-individually-labeled controls.pnpm buildgreen on the merged tree.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#74Tickets:
#76Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis is a well-scoped change: the push backend now resolves the correct target remote (branch's own upstream, or an explicit choice) instead of hardcoding
origin, the branch switcher exposes per-remote push/publish, and a broad a11y sweep wires labels to controls. The logic is sound, the security-sensitive path (remote name validation + existence check, refspec never interpolating the remote) is carefully guarded, and the Rust decision table is thoroughly unit-tested. No blockers.Correctness / design
BranchSwitcher.tsxupstreamRemoteOfvsremote.rsresolve_push_target).pushableis gated onupstreamRemoteOf(branch.upstream, remoteNames)(longest-prefix match againstgit remote), while the backend, when the UI passes noremote, resolves the target from git's%(upstream:remotename). These normally agree, but they're derived independently from two different sources. If they ever diverge (e.g. abranch.<x>.remoteconfig pointing at a remote whose name the frontend parses differently, or a transientremotes.datashape), the row is hidden or targets a remote the backend won't pick — with no signal to the user. This isn't a crash, just a maintenance/consistency hazard from two sources of truth for "which remote." Worth a short comment cross-referencing the two, or ideally surfacing the resolved remote name from the same backend data the row already has (branch.upstream), so the UI and backend can't drift.Docs / project conventions
site/src/pages/index.astro). The repo's standing brief (CLAUDE.md) requires the marketing-sitecapabilities/FeatureRowto be updated in the same change as a user-facing feature; this PR updated the README (step 1), in-app guide (step 3), and changelog fragments (step 4) but not the site (step 2). "Push/publish without switching" is likely already a site capability, so this may be a deliberate call since it's an enhancement of an existing line — but given the explicit rule, either extend the existing capability copy to mention per-remote/publish-to-any-remote or confirm the omission is intentional.Readability / consistency
useId()(RunWorkflowDialog.tsxwf-inputs-label,McpServerDialog.tsxmcp-scope). Every other file in this a11y sweep derives ids fromuseId(); these two hardcode string literals. It works today because each dialog is single-instance, but it's inconsistent with the rest of the change and would produce duplicate ids (breaking thehtmlFor/aria-labelledbyassociation) if either dialog were ever mounted twice. Switch them touseId()for consistency.Tests
Coverage of the pure decision table (
build_push_args,resolve_push_target) and the MCP wire format is genuinely thorough — request-wins/tracked/gone/untracked/force arms and the+mainsecurity guard are all exercised. The two newgit_push_coreguard paths ("remote requires an explicit branch"and"unknown remote: {r}") aren't unit-tested, but they require a real repo and fit the project's pure-logic-unit-test posture, so that's acceptable rather than a gap.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-1 dispositions (fixes locally applied + verified; in the next push):
Resolver-drift should-fix — accepted, with your "ideally" taken all the way. Rather than a cross-referencing comment, the authoritative name now ships on the Branch model:
git_branches' for-each-ref format gained%(upstream:remotename)as a 6th field →Branch.upstreamRemote(string | null), and the frontend'supstreamRemoteOflongest-prefix helper is deleted — the switcher gates onbranch.upstreamRemote+ a membership check againstremotes.data(kept solely to hide the row when the tracked remote was removed from config). UI and backend now read the samebranch.<x>.remotesource, so the drift class is gone, not papered over. New real-repo testgit_branches_reports_upstream_remote(tracked →Some("origin"), untracked →None, with the ambientbranch.autoSetupMergepinned per this file's hermeticity idiom).Site should-fix — accepted. The existing capability line was the deliberate reason for the omission (it's remote-neutral), but extending it is strictly better: now "Push or publish a branch to any remote — no checkout needed" (site/src/data/capabilities.ts;
site && pnpm buildgreen).Static-id nit — declined, file-convention grounds.
wf-inputs-labelandmcp-scopematch those files' PRE-EXISTING id conventions at baseline (wf-ref/wf-workflow/wf-pipelineandmcp-desc/mcp-commandare all static string ids from before this PR); the sweep's rule was "match the file's existing id convention", and switching only the two new ids touseId()would create in-file inconsistency with their sibling ids. Both dialogs are singletons (single conditional mount), so no duplicate-id path exists. If either file ever grows a second mount, the right move is converting ALL its ids in one pass — noted, not this PR.Also fixed this round: Copilot's error-copy find (
invalid remote name: {r}remap — thread reply has details). Verification after all fixes: cargo 771/771 green (incl. the new test), clippy clean,pnpm buildgreen, site build green.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThe gating at line 991–994 covers the trailing
ContextMenuSeparator, and thepublishRemotesternary renders nothing when not publishable — so no stray separator. The change is consistent.This PR does two disjoint things well: the push backend now resolves the actual target remote (branch's own upstream via git's
%(upstream:remotename), or an explicit choice) instead of hardcodingorigin, with the credential config scoped to that same resolved remote; and a broad a11y sweep wireshtmlFor/aria-labelledbyassociations across dialogs. The corebuild_push_args/resolve_push_targetdecision table is pure, single-sourced, and thoroughly unit-tested, and the [#76] refspec hardening is preserved (remote is only ever the barepush <remote>argv, refspecs stay fully qualified). Sound, and no blockers.Resolved since last review
BranchSwitcher.tsx) — the frontendupstreamRemoteOflongest-prefix helper is deleted; the switcher now gates onbranch.upstreamRemote(git's%(upstream:remotename), surfaced end-to-end throughgit_branches→Branch.upstream_remote→Branch.upstreamRemote) with a membership check againstremotes.datato hide rows whose tracked remote is gone. UI and backend now read the samebranch.<x>.remotesource, so the two-sources-of-truth hazard is gone. Covered by the newgit_branches_reports_upstream_remotetest.site/src/data/capabilities.tsnow reads "Push or publish a branch to any remote — no checkout needed."invalid branch nameon a bad remote (remote.rs, also flagged by Copilot) — the explicit-remote path now remaps the checker's error toinvalid remote name: {r}at the call site while keepingvalidate_ref_nameas the guard.Verified correct
git_branches6-field destructuring maps%(upstream:remotename)toupstream_remotepositionally, empty-filtered toNone; the tracked/untracked test confirms it.resolve_push_targetis called identically for bothcred_remoteand insidebuild_push_args, so the credential scope and the argv always agree on the target — no divergence between what's authenticated and what's pushed.publishRemotesternary (0 → nothing, 1 → single item, >1 → per-remote) and its trailing separator are safe: the fragment is gated bycanUpdate || behind || pushable || publishable(line 991), so the separator never renders alone.Readability
wf-inputs-label/mcp-scope(RunWorkflowDialog.tsx,McpServerDialog.tsx). Every other file in this sweep derives ids fromuseId(); these two use string literals, which would collide if the dialog were ever mounted twice (breaking thearia-labelledby/htmlForassociation). Both dialogs are single-instance today and the author notes these match each file's pre-existing static-id convention, so this is a genuine but minor consistency point — fine to leave, butuseId()would remove the latent footgun.No test gap: the pure decision table (request-wins / tracked-own-remote / copy-elsewhere / untracked-publish / force ordering /
+mainguard) and the MCPremotewire default are all exercised, and the twogit_push_coreguard paths ("remote requires an explicit branch", "unknown remote") need a real repo, consistent with the project's pure-logic unit-test posture.Posted by GitDesktop — AI output, verify before acting on it.
Related
Tickets:
#76Originally posted by: theBGuy
Round-2 update (fixes locally applied + verified; in the next push):
Static-id nit — accepting after all, at the author's direction, and taken whole. The convention-grounds decline is withdrawn: rather than converting only the two flagged ids (which would have mixed conventions inside those files — the objection on record), BOTH dialogs' full id sets are now
useId()-derived via a per-mountidBase+ suffix (the${prefix}-nameidiom AiProviderSection already uses):RunWorkflowDialog(workflow/pipeline/ref/inputs-label) andMcpServerDialog(name/desc/scope/command/args/url). The latent duplicate-mount footgun is gone file-wide, not just for the sweep's additions.CI failure — root-caused and fixed. The
buildjob'sbiome ci ./src/failed on ONE real format diff:LifecycleEditor.tsx's newrole="group"divs exceeded line width and needed wrapping — a genuine miss that had been hiding inside the local Windows CRLF noise (biome lintpasses don't catch format). Fixed with a scopedbiome check --write; the same wrap applied to the id-conversion lines it lengthened inMcpServerDialog. Scopedbiome cion all three files is now clean locally,pnpm buildgreen.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis is a sound PR. The multi-remote push backend (
resolve_push_target/build_push_args) is pure, single-sourced across the credential-scope and argv paths, keeps the [#76] refspec hardening intact, and is well-covered by the expanded decision table; the a11y sweep is mechanical label↔control association with no behavior change. No blockers, and I found no new correctness issues in the changes since the last review.Resolved since last review
RunWorkflowDialog.tsx,McpServerDialog.tsx) — both dialogs now derive their full id sets from a per-mountuseId()idBase+ suffix (${idBase}-workflow,${idBase}-scope, etc.), and thearia-labelledby/idpairs match. The latent duplicate-mount collision is gone; the earlier hardcodedwf-inputs-label/mcp-scopeliterals are eliminated.invalid branch nameon a bad explicit remote (remote.rs, Copilot's flag) — confirmed resolved in the current diff: the explicit-remote path remaps the checker error toinvalid remote name: {r}while keepingvalidate_ref_nameas the guard, then verifies existence againstgit_remotesbefore any mutation.Verified correct in this round
git_branches6-field destructuring: oneparts.next()was added at the head of the value tuple and one binding (upstream_remote) at the tail — the counts stay balanced (6 calls, 6 bindings), so%(refname:short)…%(upstream:remotename)still map positionally in sequence. The newgit_branches_reports_upstream_remotetest pins this (tracked →Some("origin"), untracked →None).LifecycleEditor.tsxrole="group"divs are pure line-wrap reflows of already-associated labels — no semantic change.fields.tsxSelectFielduseId()is already imported (line 1); the single id feeds bothLabel htmlForandSelectTrigger id, unique per instance.BranchSwitchergating still holds:publishRemotesis non-empty exactly whenpublishable, thelength === 1 ? … : maprenders nothing when empty, and the enclosing fragment is gated bycanUpdate || behind || pushable || publishable, so the trailingContextMenuSeparatornever renders alone.git_push_corecall sites (Tauri command, MCPpush,force_push, gate test) pass the newremoteargument;usePush/gitPushadd it as an optional trailing param, soSyncControls's existing call is unaffected.No new findings.
Posted by GitDesktop — AI output, verify before acting on it.
Related
Tickets:
#76Ticket changed by: theBGuy