Menu

#78 feat(branches,ui): create branch from any base (local or remote) in dialog

closed
nobody
2026-07-18
2026-07-18
Anonymous
No

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.

UI/UX

  • Adds the new BaseBranchCombobox component in src/features/repository/BaseBranchCombobox.tsx for a grouped, searchable base branch picker.
  • Integrates BaseBranchCombobox into the dialog in src/features/repository/CreateBranchDialog.tsx, replacing the previous dropdown.
  • Updates dialog logic in CreateBranchDialog.tsx to pass the noTrack flag when a remote branch is selected.
  • Removes obsolete create-branch baseOptions logic from src/features/repository/BranchSwitcher.tsx.
  • Updates help content in src/features/help/content.ts to document the new base picker behavior.

Backend/Git CLI

  • Refactors git branch creation in src-tauri/src/git/branches.rs:
  • Adds no_track argument throughout the API and uses new helper build_create_branch_args.
  • Updates git_create_branch_core to correctly pass CLI flags for --no-track.
  • Adds comprehensive unit tests for CLI arg permutations in branches.rs.
  • Extends the MCP create_branch tool in 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.
  • Adds serialization and deserialization logic to accept noTrack for agent requests and tests defaulting/explicit flag handling.

API & Queries

  • Updates gitCreateBranch in src/lib/git/api.ts to accept and forward the noTrack flag.
  • Updates the mutation in src/lib/git/queries.ts to handle the additional argument from callers.

Documentation & Changelog

  • Expands documentation in README.md to describe starting branches from any base.
  • Adds an entry in changelog.d/added-create-branch-from-any-base.md.
  • Updates product capability table in site/src/data/capabilities.ts with this enhancement.

Discussion

  • Anonymous

    Anonymous - 2026-07-18
     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI security audit · opus · automated


    No security issues found in this diff. The one security-relevant sink — the new build_create_branch_args / no_track path feeding run_git_mutating — is safe: git is spawned with an argument array (no shell), --no-track is a fixed boolean-gated literal, and both name and start_point are still passed through validate_ref_name (rejects leading -, * ? [ : \ space and control chars) before any argv is built, so neither argument nor refspec injection is reachable. The frontend additions are plain React text rendering (the el.title assignment is a benign DOM property, no dangerouslySetInnerHTML), and the MCP noTrack field is a defaulted bool.


    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🧭 Context for reviewers — deliberate decisions on this diff (dispositions pre-recorded so review rounds can ground against them)

    1. Remote refs are deliberately NOT deduped against same-named locals in the base picker. The motivating scenario is a same-named pair: local epic/x stale (or checked out in another worktree) while origin/epic/x holds the fresh tip. Dedupe-to-local is correct for the switcher's checkout list (clicking a remote row creates the same-named local — BranchSwitcher's remoteOnly) 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 named origin/x (would duplicate a combobox value).
    2. --no-track applies ONLY to remote-tracking bases. Without it, switch -c slice2 origin/epic/x auto-tracks the epic ref, and a later push {branch: "slice2"} builds refs/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); the no_track=false argv is pinned by the 8-case build_create_branch_args test table.
    3. MCP wire name is a field-level #[serde(rename = "noTrack")]CreateBranchArgs has no rename_all, so a bare #[serde(default)] would deserialize only no_track and silently drop the documented camelCase key (default-false ⇒ the footgun quietly returns). The wire key is pinned by create_branch_args_no_track_defaults_false_and_parses_true. MCP default is false ⇒ zero behavior change for existing agents; force_push remains HEAD-only by design.
    4. The "Base it on" label is not programmatically associated with the combobox trigger — pre-existing repo idiom, not a regression. 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-off aria-labelledby here.
    5. Accepted benign edge: if a picked remote branch is deleted and the 30s-stale remote list refetches mid-dialog, submit still sends the stale start point → git rejects with a clear error (surfaced via toast). No silent wrong-branch path exists.
    6. Live-validated (real repo with main deliberately 3 behind origin/main): grouped Local/Remote rendering, per-group filtering, empty-group drop (origin/ filter → Remote only), "No branches match" empty state; create off origin/main → tip == remote tip, git reports no upstream configured, header flips to Publish (the [#76] pairing); control create off local main → local tip. MCP path validated end-to-end over real stdio (gitdesktop mcp --allow-git-write, tools/call with noTrack: true → branch at remote tip, zero tracking config). Rust: 763 tests green, clippy -D warnings clean; pnpm build + site build green.

    Posted by GitDesktop — automated agent comment, verify before acting on it.

     

    Related

    Tickets: #76

  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    Summary

    This adds a searchable, grouped (Local/Remote) base-branch picker to the create-branch dialog and threads a --no-track flag from the UI and the MCP tool through to git 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

    • should-fixsrc-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 when no_track=true and basing on a remote-tracking ref, versus a tracking branch when no_track=false. The whole feature premise ("first push publishes under its own name") rides on --no-track being 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 local origin/x-style tracking ref, runs git_create_branch_core with no_track=true, and asserts the new branch's @{upstream} is unset (and set when false). This is the regression that the argv test can't catch.

    Edge cases

    • nitsrc/features/repository/CreateBranchDialog.tsx (hasBases) vs BaseBranchCombobox.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 (drops gd/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 → hasBases is true, so "Base it on" renders, but groups is 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 deriving hasBases from the filtered names (or letting the combobox own its own empty/hidden state) so they stay in sync.

    Everything else checks out: no_track is optional-defaulted at every boundary (api.ts sends noTrack ?? false, the mutation arg is optional, the other caller in HistoryPanel.tsx is unaffected, serde defaults to false), baseIsRemote and the form's base value are always set together so they can't desync, validate_ref_name still guards both name and start_point against refspec injection, and the grouped Base UI render-function + sibling ComboboxEmpty structure follows the established working pattern.


    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    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_repo in branches.rs closes the argv→outcome gap: it synthesizes refs/remotes/origin/x via update-ref (nothing fetched), drives git_create_branch_core directly with an AppState::default() (the remote.rs real-repo idiom — so validation + argv build + run_git_mutating are all exercised), and asserts both arms: no_track=truey@{upstream} fails to resolve AND y == origin/x's tip; no_track=false control ⇒ 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.

    hasBases drift 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 exported useHasBaseOptions(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 warnings clean, pnpm build green, scoped biome clean.


    Posted by GitDesktop — automated agent comment, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    This adds a searchable, grouped (Local/Remote) base-branch picker to the create-branch dialog and threads a --no-track flag 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

    • Test gap (--no-track argv→outcome) — closed. create_branch_honors_no_track_against_real_repo in branches.rs now drives git_create_branch_core against a real repo with a synthesized refs/remotes/origin/x, asserting y@{upstream} is unset in the no_track=true arm and equals origin/x in the control arm — exactly the regression the pure argv table can't catch. Imports (run_git, DEFAULT_TIMEOUT, AppState, stdout_lossy) all exist.
    • hasBases derivation drift — closed. isOfferableLocal/isOfferableRemoteName are now module-level predicates consumed by both the picker's group derivations and the new useHasBaseOptions gate the dialog uses, so the field and dropdown share one code path.

    Edge cases

    • nitCreateBranchDialog.tsx, seedOnOpen (line 105). The seed fallback guards only the gd/session/* namespace, but the same "seeded value absent from the list" class it fixes can recur when the fallback lands on an archived default: if currentName is null (detached HEAD) or a session branch, seedBase becomes defaultName, and isOfferableLocal drops an archived defaultName (archived && not current). With any remote present, hasBases is true so the field renders, the trigger shows defaultName, 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 through isOfferableLocal before seeding.

    Accessibility

    • nitCreateBranchDialog.tsx (line 182). The <Label>Base it on</Label> isn't programmatically tied to the combobox trigger (no htmlFor/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-wide SelectField/TextField idiom, 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 in seedOnOpen; not an issue in the current diff. I also confirmed the required no_track: bool Tauri param doesn't break the other caller: HistoryPanel.tsx's useCreateBranch omits noTrack, the optional mutation arg + api.ts's noTrack ?? false send false, so the command always receives it.


    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    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 to useHasBaseOptions, returning the first of [current, default] that passes the same module-level isOfferableLocal predicate 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 build green, 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: #4

  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    Summary

    This threads a --no-track flag 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_core has exactly two callers (the Tauri command and the MCP handler), both updated; the other useCreateBranch consumer (HistoryPanel.tsx:157) omits noTrack, which flows through the optional mutation arg and api.ts's noTrack ?? false to send false, so the command always receives it. baseIsRemote and field.state.value are only ever set together (in seedOnOpen and onValueChange), so they can't diverge and drive a wrong --no-track.

    Resolved since last review

    • Archived-default seed nit — closed. The inline gd/session/* ternary in seedOnOpen is gone; useSeedBase now runs each candidate [currentName, defaultName] through isOfferableLocal, so an archived default reached via a null/session currentName resolves to false and 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.)
    • hasBases derivation drift — remains closed; useHasBaseOptions/useSeedBase/the picker groups all consume the module-level isOfferableLocal/isOfferableRemoteName.

    Tests

    • nitbranches.rs, create_branch_honors_no_track_against_real_repo (control arm, ~line 1044). The no_track=false assertion z should track origin/x depends on the ambient global branch.autoSetupMerge default. The test only sets repo-local user.name/user.email; it doesn't pin the tracking mode. A contributor whose global config has branch.autoSetupMerge = simple (or false) would get git branch z origin/x creating z with no upstream (simple only tracks when the local name matches the remote name), so z@{upstream} fails to resolve and the assertion false-fails even though the code is correct. The no_track=true arm is hermetic (--no-track overrides config), so only the control arm is exposed. Fix: pin it repo-locally before creating z, e.g. run(&repo_s, &["config", "branch.autoSetupMerge", "true"]).await;.

    Notes on prior/other findings

    • Copilot's session-branch seed finding (CreateBranchDialog.tsx:111) and the earlier archived-default nit are both addressed by useSeedBase in the current diff — not issues here.
    • Copilot's a11y finding (BaseBranchCombobox/<Label>Base it on</Label> has no htmlFor/aria-labelledby) is real but deliberately deferred to the shared field-primitives sweep and matches the app-wide SelectField/TextField idiom; the in-code comment records the deferral. No change needed here.

    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    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_GLOBAL pointing at a config carrying branch.autoSetupMerge = simple, git branch z origin/x indeed creates z with no upstream (fatal: no upstream configured) — and the same under false — so the control arm would false-fail on a contributor's customized machine (CI runners default-true and never see it). A repo-local branch.autoSetupMerge = true was then confirmed to defeat the ambient global (branch 'z2' set up to track 'origin/x'). Applied exactly as suggested: the test now pins config branch.autoSetupMerge true repo-locally before the control arm, with a comment noting the --no-track arm 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.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    This threads a --no-track option 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-grouped 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, 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_core has exactly the two callers shown (Tauri command + MCP handler), both updated to pass no_track; api.ts maps noTrack ?? false so the non-optional Rust no_track: bool always receives a value, and the second useCreateBranch consumer that omits noTrack flows through as false.
    • The hasBases gate can't render an empty dropdown: useHasBaseOptions relies on remotes only when hasLocal is 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.
    • baseIsRemote and field.state.value can only be set together (onValueChange sets both; seedOnOpen resets both — base to an offerable local/"" and baseIsRemote to false), so they can't diverge and drive a wrong --no-track. noTrack: baseIsRemote && Boolean(startPoint) with startPoint = value.base || undefined correctly suppresses the flag when no start point is chosen.
    • build_create_branch_args places --no-track correctly 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

    • Test-hermeticity nit (branches.rs, create_branch_honors_no_track_against_real_repo control arm) — resolved. The current diff pins git config branch.autoSetupMerge true repo-locally before creating z, so a contributor whose ambient global sets simple/false no longer false-fails the z@{upstream} assertion. The no_track=true arm remains config-immune.

    Nits

    • nitCreateBranchDialog.tsx:188, <Label>Base it on</Label> has no htmlFor/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 existing SelectField/TextField idiom, so it's fine to land as-is here rather than adding a one-off aria-label that 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: useSeedBase runs the [current, default] candidates through the same isOfferableLocal predicate (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.

     
  • Anonymous

    Anonymous - 2026-07-18

    Ticket changed by: theBGuy

    • status: open --> closed
     

Log in to post a comment.