feat(branches,ui): create branch from any base (local or remote) in dialog
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
This change allows users to create a new branch from any base—local or remote—directly in the branch creation dialog. Previously, the dialog only supported creating a branch from the current or default branch. This enhancement makes it possible to start work from arbitrary local or remote branch tips, better supporting workflows such as branching off remote feature branches. When a branch is based on a remote-tracking ref, it is created with no upstream, so its first push publishes under its own name.
BaseBranchCombobox component in src/features/repository/BaseBranchCombobox.tsx for a grouped, searchable base branch picker.BaseBranchCombobox into the dialog in src/features/repository/CreateBranchDialog.tsx, replacing the previous dropdown.CreateBranchDialog.tsx to pass the noTrack flag when a remote branch is selected.src/features/repository/BranchSwitcher.tsx.src/features/help/content.ts to document the new base picker behavior.src-tauri/src/git/branches.rs:no_track argument throughout the API and uses new helper build_create_branch_args.git_create_branch_core to correctly pass CLI flags for --no-track.branches.rs.src-tauri/src/mcp_server/write_git.rs to accept and document the noTrack option (wire format and docs), passing this through to the core implementation.noTrack for agent requests and tests defaulting/explicit flag handling.gitCreateBranch in src/lib/git/api.ts to accept and forward the noTrack flag.src/lib/git/queries.ts to handle the additional argument from callers.README.md to describe starting branches from any base.changelog.d/added-create-branch-from-any-base.md.site/src/data/capabilities.ts with this enhancement.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
115281dView logs
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo security issues found in this diff. The one security-relevant sink — the new
build_create_branch_args/no_trackpath feedingrun_git_mutating— is safe: git is spawned with an argument array (no shell),--no-trackis a fixed boolean-gated literal, and bothnameandstart_pointare still passed throughvalidate_ref_name(rejects leading-,* ? [ : \ spaceand control chars) before any argv is built, so neither argument nor refspec injection is reachable. The frontend additions are plain React text rendering (theel.titleassignment is a benign DOM property, nodangerouslySetInnerHTML), and the MCPnoTrackfield is a defaulted bool.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🧭 Context for reviewers — deliberate decisions on this diff (dispositions pre-recorded so review rounds can ground against them)
epic/xstale (or checked out in another worktree) whileorigin/epic/xholds the fresh tip. Dedupe-to-local is correct for the switcher's checkout list (clicking a remote row creates the same-named local —BranchSwitcher'sremoteOnly) but wrong for a base picker, where the two refs can be different commits. The only remote rows dropped:gd/session/*, and a full-value collision with a local literally namedorigin/x(would duplicate a combobox value).--no-trackapplies ONLY to remote-tracking bases. Without it,switch -c slice2 origin/epic/xauto-tracks the epic ref, and a laterpush {branch: "slice2"}buildsrefs/heads/slice2:refs/heads/epic/x(#76's tracked-upstream refspec) — a clean fast-forward that lands the slice's commits on the epic branch. Local bases are byte-identical to pre-change behavior (git doesn't auto-track local start points); theno_track=falseargv is pinned by the 8-casebuild_create_branch_argstest table.#[serde(rename = "noTrack")]—CreateBranchArgshas norename_all, so a bare#[serde(default)]would deserialize onlyno_trackand silently drop the documented camelCase key (default-false ⇒ the footgun quietly returns). The wire key is pinned bycreate_branch_args_no_track_defaults_false_and_parses_true. MCP default is false ⇒ zero behavior change for existing agents;force_pushremains HEAD-only by design.SelectField(src/lib/form) renders the same bare-<Label>pattern. The proper fix is a cross-cutting sweep of the shared field primitives (queued as a follow-up), not a one-offaria-labelledbyhere.maindeliberately 3 behindorigin/main): grouped Local/Remote rendering, per-group filtering, empty-group drop (origin/filter → Remote only), "No branches match" empty state; create offorigin/main→ tip == remote tip, git reports no upstream configured, header flips to Publish (the [#76] pairing); control create off localmain→ local tip. MCP path validated end-to-end over real stdio (gitdesktop mcp --allow-git-write,tools/callwithnoTrack: true→ branch at remote tip, zero tracking config). Rust: 763 tests green,clippy -D warningsclean;pnpm build+ site build green.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#76Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedSummary
This adds a searchable, grouped (Local/Remote) base-branch picker to the create-branch dialog and threads a
--no-trackflag from the UI and the MCP tool through togit switch/branch, so branching off a remote-tracking ref yields an untracked branch. The change is well-scoped, well-commented, doc-synced (README, capabilities, help, changelog), and the Rust argv decision table is thoroughly unit-tested. No blockers — I have one tests gap worth closing and one minor edge case.Tests
src-tauri/src/git/branches.rs,build_create_branch_args/git_create_branch_core. The eight argv permutation tests are strong but only assert the argument vector; nothing verifies git actually produces the guaranteed outcome — a branch with no upstream whenno_track=trueand basing on a remote-tracking ref, versus a tracking branch whenno_track=false. The whole feature premise ("first push publishes under its own name") rides on--no-trackbeing honored in this specific position (switch --no-track -c <name> <start>with no--), which a pure argv test that hardcodes the expected vector can't confirm. The repo already has tokio real-repo test infrastructure; add one that creates a localorigin/x-style tracking ref, runsgit_create_branch_corewithno_track=true, and asserts the new branch's@{upstream}is unset (and set whenfalse). This is the regression that the argv test can't catch.Edge cases
src/features/repository/CreateBranchDialog.tsx(hasBases) vsBaseBranchCombobox.tsx(groups). Field visibility is gated on the raw query counts (branches.data?.length,remoteBranches.data?.length), but the combobox builds its list from a filtered set (dropsgd/session/*and archived-non-current). These can disagree: e.g. a detached HEAD (no current branch) in a repo whose only locals are archived and with no remotes →hasBasesis true, so "Base it on" renders, butgroupsis empty and the picker shows an empty dropdown. It's a contrived state and not harmful, but the two derivations of the same "is there anything to base on?" decision can drift. Consider derivinghasBasesfrom the filtered names (or letting the combobox own its own empty/hidden state) so they stay in sync.Everything else checks out:
no_trackis optional-defaulted at every boundary (api.tssendsnoTrack ?? false, the mutation arg is optional, the other caller inHistoryPanel.tsxis unaffected, serde defaults tofalse),baseIsRemoteand the form'sbasevalue are always set together so they can't desync,validate_ref_namestill guards bothnameandstart_pointagainst refspec injection, and the grouped Base UI render-function + siblingComboboxEmptystructure follows the established working pattern.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):
Tests should-fix — accepted. New real-repo tokio test
create_branch_honors_no_track_against_real_repoin branches.rs closes the argv→outcome gap: it synthesizesrefs/remotes/origin/xviaupdate-ref(nothing fetched), drivesgit_create_branch_coredirectly with anAppState::default()(the remote.rs real-repo idiom — so validation + argv build +run_git_mutatingare all exercised), and asserts both arms:no_track=true⇒y@{upstream}fails to resolve ANDy==origin/x's tip;no_track=falsecontrol ⇒z@{upstream}==origin/x. You're right that the argv table alone can't catch git declining/repositioning the flag — the live validation proved it once, this makes it a regression net.hasBasesdrift nit — accepted, with the single-derivation constraint kept. Rather than duplicating the filters in the dialog, the offerability predicates (isOfferableLocal,isOfferableRemoteName) moved to module level in BaseBranchCombobox.tsx and are consumed by BOTH the component's group derivations and a new exporteduseHasBaseOptions(repoPath, open, currentName)hook that the dialog now gates on — the two decisions share one code path and can't drift. (The remote full-value collision drop stays inside the component; it can't affect emptiness, since a colliding local implies a non-empty Local group.)Also folded in: Copilot's session-branch seed fallback (thread reply has details). Verification after all three: cargo 764/764 green (incl. the new tokio test),
clippy -D warningsclean,pnpm buildgreen, scoped biome clean.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis adds a searchable, grouped (Local/Remote) base-branch picker to the create-branch dialog and threads a
--no-trackflag end-to-end (UI, mutation, Tauri command, MCP tool) so branching off a remote-tracking ref yields an untracked branch that publishes under its own name. The change is well-scoped, thoroughly commented, doc-synced, and well-tested; I found no blockers.Resolved since last review
--no-trackargv→outcome) — closed.create_branch_honors_no_track_against_real_repoinbranches.rsnow drivesgit_create_branch_coreagainst a real repo with a synthesizedrefs/remotes/origin/x, assertingy@{upstream}is unset in theno_track=truearm and equalsorigin/xin the control arm — exactly the regression the pure argv table can't catch. Imports (run_git,DEFAULT_TIMEOUT,AppState,stdout_lossy) all exist.hasBasesderivation drift — closed.isOfferableLocal/isOfferableRemoteNameare now module-level predicates consumed by both the picker's group derivations and the newuseHasBaseOptionsgate the dialog uses, so the field and dropdown share one code path.Edge cases
CreateBranchDialog.tsx,seedOnOpen(line 105). The seed fallback guards only thegd/session/*namespace, but the same "seeded value absent from the list" class it fixes can recur when the fallback lands on an archived default: ifcurrentNameis null (detached HEAD) or a session branch,seedBasebecomesdefaultName, andisOfferableLocaldrops an archiveddefaultName(archived && not current). With any remote present,hasBasesis true so the field renders, the trigger showsdefaultName, but that value isn't in the list. It's cosmetic only — submit still resolves the archived branch at the git layer — and the state is contrived (detached/session HEAD + archived default + a remote), so it's low priority; if you want full symmetry, run the fallback value throughisOfferableLocalbefore seeding.Accessibility
CreateBranchDialog.tsx(line 182). The<Label>Base it on</Label>isn't programmatically tied to the combobox trigger (nohtmlFor/aria-labelledby), so clicking the label doesn't focus it and the accessible name relies on the trigger's rendered value text. This is explicitly deferred to the planned shared field-primitives sweep and matches the app-wideSelectField/TextFieldidiom, so no change is needed here — noting only so the deferral is a conscious call. (Copilot flagged this too.)Copilot's other finding — the session-branch seed producing an unlistable value — is addressed by the
gd/session/*fallback now inseedOnOpen; not an issue in the current diff. I also confirmed the requiredno_track: boolTauri param doesn't break the other caller:HistoryPanel.tsx'suseCreateBranchomitsnoTrack, the optional mutation arg +api.ts'snoTrack ?? falsesendfalse, so the command always receives it.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 disposition (fix locally applied + verified; in the next push):
Archived-default seed nit — accepted, as a class-closure rather than another instance guard. The round-1 fix guarded one namespace; this round's instance (archived default via the detached/session fallback) shows the real invariant is broader: the seed must only ever be a value the picker would offer. A new
useSeedBase(repoPath, currentName, defaultName)hook now lives in BaseBranchCombobox.tsx next touseHasBaseOptions, returning the first of [current, default] that passes the same module-levelisOfferableLocalpredicate the groups and the visibility gate use (bare session-namespace check only while branch data is momentarily unloaded, preserving prior behavior), else""⇒ create from HEAD. The dialog's inline namespace ternary is deleted — seed, groups, and field-visibility now share one predicate source, so no further "seeded value absent from the list" instance can exist. Verified:pnpm buildgreen, scoped biome clean.A11y note — confirmed as recorded: the label-association deferral to the shared field-primitives sweep stands (context disposition [#4]; your framing "a conscious call" is exactly right).
Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#4Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedSummary
This threads a
--no-trackflag end-to-end (UI → mutation → Tauri command → MCP tool) and replaces the create-branch dialog's two-option base dropdown with a searchable, grouped (Local/Remote)BaseBranchCombobox, so branching off a remote-tracking ref yields an untracked branch that publishes under its own name. The change is well-scoped, correctly wired, doc-synced (README, capabilities, help, changelog fragment all present), and well-tested. No blockers — I found only one minor test-hermeticity nit.I verified the pieces that could silently break:
git_create_branch_corehas exactly two callers (the Tauri command and the MCP handler), both updated; the otheruseCreateBranchconsumer (HistoryPanel.tsx:157) omitsnoTrack, which flows through the optional mutation arg andapi.ts'snoTrack ?? falseto sendfalse, so the command always receives it.baseIsRemoteandfield.state.valueare only ever set together (inseedOnOpenandonValueChange), so they can't diverge and drive a wrong--no-track.Resolved since last review
gd/session/*ternary inseedOnOpenis gone;useSeedBasenow runs each candidate[currentName, defaultName]throughisOfferableLocal, so an archived default reached via a null/sessioncurrentNameresolves tofalseand the seed falls to""(⇒ HEAD) instead of rendering an unlistable value. Seed, group derivation, and the visibility gate now share the one predicate. (Only residual is the momentary branches-data-unloaded fallback, which is contrived and cosmetic — submit still resolves at the git layer.)hasBasesderivation drift — remains closed;useHasBaseOptions/useSeedBase/the picker groups all consume the module-levelisOfferableLocal/isOfferableRemoteName.Tests
branches.rs,create_branch_honors_no_track_against_real_repo(control arm, ~line 1044). Theno_track=falseassertionz should track origin/xdepends on the ambient globalbranch.autoSetupMergedefault. The test only sets repo-localuser.name/user.email; it doesn't pin the tracking mode. A contributor whose global config hasbranch.autoSetupMerge = simple(orfalse) would getgit branch z origin/xcreatingzwith no upstream (simpleonly tracks when the local name matches the remote name), soz@{upstream}fails to resolve and the assertion false-fails even though the code is correct. Theno_track=truearm is hermetic (--no-trackoverrides config), so only the control arm is exposed. Fix: pin it repo-locally before creatingz, e.g.run(&repo_s, &["config", "branch.autoSetupMerge", "true"]).await;.Notes on prior/other findings
CreateBranchDialog.tsx:111) and the earlier archived-default nit are both addressed byuseSeedBasein the current diff — not issues here.BaseBranchCombobox/<Label>Base it on</Label>has nohtmlFor/aria-labelledby) is real but deliberately deferred to the shared field-primitives sweep and matches the app-wideSelectField/TextFieldidiom; the in-code comment records the deferral. No change needed here.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-3 disposition (fix locally applied + verified; in the next push):
Test-hermeticity nit — accepted; both the finding and your suggested mechanism verified by live probe before applying. With
GIT_CONFIG_GLOBALpointing at a config carryingbranch.autoSetupMerge = simple,git branch z origin/xindeed createszwith no upstream (fatal: no upstream configured) — and the same underfalse— so the control arm would false-fail on a contributor's customized machine (CI runners default-true and never see it). A repo-localbranch.autoSetupMerge = truewas then confirmed to defeat the ambient global (branch 'z2' set up to track 'origin/x'). Applied exactly as suggested: the test now pinsconfig branch.autoSetupMerge truerepo-locally before the control arm, with a comment noting the--no-trackarm is immune (flag overrides config). Orchestrator-applied (2 lines, within the disclosed trivial-fix carve-out); full suite re-run green: 764/764.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis threads a
--no-trackoption end-to-end (dialog → mutation → Tauri command → MCP tool) and swaps the create-branch dialog's two-item base dropdown for a searchable, Local/Remote-groupedBaseBranchCombobox, so branching off a remote-tracking ref yields an untracked branch that publishes under its own name. The change is well-scoped, correctly wired, and doc-synced (README, capabilities, help, changelog all present); I traced the wiring and edge cases and found no blockers.Verification notes:
git_create_branch_corehas exactly the two callers shown (Tauri command + MCP handler), both updated to passno_track;api.tsmapsnoTrack ?? falseso the non-optional Rustno_track: boolalways receives a value, and the seconduseCreateBranchconsumer that omitsnoTrackflows through asfalse.hasBasesgate can't render an empty dropdown:useHasBaseOptionsrelies on remotes only whenhasLocalis false, and the combobox's collision drop (!localSet.has(r.value)) only removes a remote when an offerable local with that exact name exists — which forces the Local group non-empty. So "field shown, zero rows" is unreachable, matching the code's stated invariant.baseIsRemoteandfield.state.valuecan only be set together (onValueChangesets both;seedOnOpenresets both — base to an offerable local/""andbaseIsRemotetofalse), so they can't diverge and drive a wrong--no-track.noTrack: baseIsRemote && Boolean(startPoint)withstartPoint = value.base || undefinedcorrectly suppresses the flag when no start point is chosen.build_create_branch_argsplaces--no-trackcorrectly for both arms (switch --no-track -c/branch --no-track --), and the real-repo test now exercises argv→outcome for both tracking modes.Resolved since last review
branches.rs,create_branch_honors_no_track_against_real_repocontrol arm) — resolved. The current diff pinsgit config branch.autoSetupMerge truerepo-locally before creatingz, so a contributor whose ambient global setssimple/falseno longer false-fails thez@{upstream}assertion. Theno_track=truearm remains config-immune.Nits
CreateBranchDialog.tsx:188,<Label>Base it on</Label>has nohtmlFor/aria-labelledby, so the combobox trigger lacks a programmatic accessible name (Copilot flagged this too). It's a real gap, but it's a deliberate, in-code-documented deferral to the shared field-primitives a11y sweep and is consistent with the existingSelectField/TextFieldidiom, so it's fine to land as-is here rather than adding a one-offaria-labelthat would diverge from that sweep.Copilot's other finding (
CreateBranchDialog.tsx:111, session-branch seed rendering an unselectable value) is not an issue in the current diff:useSeedBaseruns the[current, default]candidates through the sameisOfferableLocalpredicate (session namespace excluded even in the data-unloaded fallback), so the seed is always a value the picker would offer or""(⇒ HEAD).Posted by GitDesktop — AI output, verify before acting on it.
Ticket changed by: theBGuy