feat(pulls,github,mcp): manage PR stacks and retarget base branches
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
Stacked PRs were read-only until now — GitDesktop could show a stack and merge one, but the stack itself had to be created elsewhere. This adds the write side on GitHub (create, add to, dissolve), and lets the Edit dialog retarget a pull request's base branch on GitHub, GitLab, and Bitbucket. It also hardens the list's stack join so an absent badge is never mistaken for a firm "not stacked".
gh_stack_create, gh_stack_add, and gh_stack_dissolve in src-tauri/src/github/pr.rs, with StackWriteOutcome (stack number + confirmed members) and the pure helpers stack_write_args (typed -F pull_requests[]=…) and stack_write_outcome_from, which treats an unreadable success body as an error rather than a default outcome.forge_stack_create / forge_stack_add / forge_stack_dissolve commands in src-tauri/src/forge/mod.rs; the GitLab and Bitbucket arms return actionable errors (GitLab infers chains, Bitbucket has no stacks) instead of falling through to the gh path. Registered in src-tauri/src/lib.rs.gh_open_stack_memberships now returns Option<…> — None means the probe itself failed (GHES, spawn/exit error, unparseable body), which the new apply_stack_join turns into stack_unknown: true on every row rather than a silently unstacked-looking list.--paginate --slurp) with flatten_slurped_pages, because merge-dissolved stacks persist as open: false and crowd the early pages on a long-lived repo.PrInfo carries stack_unknown (src-tauri/src/github/pr.rs, mirrored as stackUnknown in src/lib/git/types.ts); forge/gitlab.rs and forge/bitbucket.rs set it false explicitly, each with a note on why they have no probe to fail.forge_pr_edit gains an optional base: GitHub passes it through gh_pr_edit, GitLab appends target_branch via the new pure edit_mr_args, and Bitbucket adds destination.branch.name in build_edit_body. Every arm omits the field when base is None, so an unchanged picker never sends a retarget.edit_mr_args_append_target_branch_only_when_given (forge/gitlab.rs) and edit_body_carries_destination_only_when_retargeting (forge/bitbucket.rs), the latter asserting title/description/reviewers are untouched.src-tauri/src/mcp_server/write_forge.rs — create_pull_request_stack, add_to_pull_request_stack, dissolve_pull_request_stack — with CreatePullRequestStackArgs, AddToPullRequestStackArgs, and StackNumberArg; descriptions spell out the bottom→top ordering contract and the append-on-top-only limit.update_pull_request takes a base argument, documented as not requiring the preserve-read that title/body need.read_forge.rs documents stackUnknown on list_pull_requests; mcp_server/mod.rs updates the capability blurb, bumps the router count assertion 119 → 122, and adds dissolve_pull_request_stack to the destructive-tool set.src/lib/git/stack-chains.ts holds detectStackOffer — a pure, total chain detector that mirrors the GitLab infer_mr_stacks topology (ambiguity poisons the whole connected component; cycles yield nothing), layers GitHub's write rules on top (append only onto a stack's open top; two members minimum for a create), and voids the whole list when any row is stackUnknown. isNativeStack moves here from StackSection.tsx, which re-exports it.src/features/pulls/StackSection.tsx gains StackHeader with an always-visible Dissolve button (destructive tone on hover/focus only), plus the StackOffer component and its StackOfferHandle imperative expand() so palette commands land on the same confirm path as the button.src/features/pulls/RemotePrView.tsx wires it up: a strictly gated second open-PR list fetch behind offerEnabled, create/add confirmation with forge-reported member counts in the toast, write errors rendered beside the affordance instead of a toast, a dissolveStackNumber parse guard that withdraws the action rather than sending a NaN, a confirmed dissolve via useConfirm, and a base-branch Select in the Edit dialog fed by useBranchPickerOptions.forgeStackCreate / forgeStackAdd / forgeStackDissolve and the base parameter on forgePrEdit in src/lib/git/api.ts (explicit null for "no retarget", since undefined is dropped by IPC), with useStackCreate / useStackAdd / useStackDissolve and an extended useEditPr in src/lib/git/queries.ts, and StackWriteOutcome in src/lib/git/types.ts.pr-stack-create, pr-stack-add, and pr-stack-dissolve in src/lib/hotkeys/registry.ts (no default binding).src/lib/error-summary.ts also strips a leading gh: prefix so a GitHub CLI failure reads cleanly in a one-line summary.README.md extends the stacked-PR bullet with create / add / dissolve and the base-retarget capability.site/src/data/capabilities.ts updates the stacked-PR entry and adds a retarget entry.src/features/help/content.ts documents the Edit dialog's base-branch select (including why it's disabled on a stacked GitHub PR), the create/add offer and its preview, Dissolve and the dissolve-and-recreate workaround for reordering, and the three new palette commands.changelog.d/added-stack-management.md fragment.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
c659371View logs
Originally posted by: theBGuy
Context for reviewers — deliberate calls and probe-verified facts, numbered for reference:
POST /stacks/{n}/unstackreturns 204 and dissolves the whole stack; thepull_requestsrequest body is completely ignored (probed with a middle member, the top member, a PR not in the stack, and a nonexistent number — every variant dissolved everything). There is no partial-remove primitive, so the app ships Dissolve behind a destructive confirm; per-PR remove is deferred with a home (backlog: Stacked-PRs Phase 3).errors[]: "Cannot change the base branch because the pull request is part of a stack." The Edit dialog pre-gates known-stacked GitHub PRs (select disabled + a visible dissolve-first explanation); thestackUnknownarm lets the server arbitrate. Because gh discardserrors[]detail to a bare "Validation Failed (HTTP 422)" on stderr, every gh-api write in this PR routes through one helper that recovers the detail from the response body. (Probe note: the create/add validation messages ride top-levelmessageand already survive gh's stderr — the helper is concept-level hardening, not a bug fix.)size, so a stack whose top merged offers nothing (fail-closed). A race surfaces the server's own message verbatim in the offer's error line.stackUnknownand the offer refuses to render — a failed probe can no longer mint a false "create stack" preview over rows that are actually stacked. A join timeout therefore suppresses offers entirely; deliberate — the preview must never assert what it can't know.GET /stacksretains merge-dissolved stacks asopen:falseforever (live-probed) — a long-lived repo's first page fills with residue. Still bounded by STACKS_TIMEOUT, past which the list renders without decoration — which, per item 5, now reads as unknown rather than "no stacks".merge_action) — merge queues are org-repo-only and the fixtures are personal repos. Unchanged posture from [#142], same backlog home.baserides the wire as an explicit null when unset (the repo's optional-invoke-arg idiom — IPC serialization drops undefined keys; serde reads null as None). All three arms send their base / target_branch / destination field only when set; unset is unit-tested byte-identical to today's requests on all three.infer_mr_stacks) is unit-tested, and the TS carries comments kept diffable against the Rust rules.--all-targetslints in#[cfg(test)]code (generate.rs:2901/2927 MutexGuard-held-across-await; read_git.rs:431) — measured on two separate runs during this build, invisible to the repo's standard clippy gate (no--all-targets), on record in the backlog.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#142Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedAdds the GitHub stack write side (create / add / dissolve via a contextual offer, three palette actions, three MCP tools), base-branch retargeting on all three forges, and turns the list's stack join into a tri-state (
stackUnknown) with a paginated--slurpread. The design is sound and unusually well fenced — fail-closed offers, a bounded decoration read,-Fused only on formattedu64s, and all four doc surfaces (README,capabilities.ts, help guide,changelog.d/) carried in the same change. Nothing blocking; one should-fix in the chain detector.Correctness
src/lib/git/stack-chains.ts,detectStackOffer(thesourcesmap, ~line 80): parent links are matched on branch name alone, and the rows it consumes carry no cross-repo marker (PR_LIST_FIELDSinsrc-tauri/src/github/pr.rs:1640requests onlyheadRefName/baseRefName), so a fork PR is indistinguishable from a same-repo one. Concrete case, common in any public repo: PR [#10] arrives from a contributor's fork with head branchmaininto basemain; your PR [#12] is headfeature-x, basemain.sources["main"] = [10], so [#12] gets exactly one parent candidate (not ambiguous), [#10] is parentless (its own basemainresolves only to itself and is filtered), and the component[10, 12]is a clean unstacked chain → a "create stack" offer whose preview names a stranger's fork PR as the stack bottom.confirmStackOffer(RemotePrView.tsx:518) then sendsstackOffer.memberstoforge_stack_create→gh_stack_create, which the forge rejects because [#10]'s head branch doesn't exist in this repo — after a preview that asserted a chain that was never there. Fix: addisCrossRepository(agh pr list --jsonfield, as isheadRepositoryOwner) toPR_LIST_FIELDS, add#[serde(default)] pub cross_repository: booltoPrInfo(github/pr.rs:534) — which obliges setting itfalsein every literal construction site:from_bb_pr(forge/bitbucket.rs),from_glab_mr(forge/gitlab.rs),rest_pull_to_pr_info(github/pr.rs), and the two test builders (pr_rowinpr.rstests, the gitlab testinfo()helper) — mirror it ascrossRepository?: booleanonPrInfoinsrc/lib/git/types.ts, and indetectStackOfferdrop those rows fromrowsalongside the existing state filter (a PR whose head lives in another repository can never be a stack member), extending the doc comment's rule list with that exclusion.Edge cases
src/features/pulls/RemotePrView.tsx:516,545(stackWriteError/cancelStackOffer): the create/add mutation error is cleared only by Cancel or by the PR-change effect at :557, whileStackOfferresetsexpandedon its ownofferKeychange (StackSection.tsx, ~line 300). A failed create, then a chain change under the same PR (another PR opened on top), collapses the preview but keeps the error — re-expanding shows the previous chain's forge message above a different preview. Concrete fix: export astackOfferKey(offer)helper fromstack-chains.ts, use it both forStackOffer'slastKeyand in the parent's existing reset effect (useEffect(..., [number, offerKeyOrEmpty])), so the two identities can't drift.src/features/pulls/RemotePrView.tsx:500: detection sees at most 100 open PRs, so on a repo with more, a chain member outside that page turns what should be an "add to stack #N" into a "create stack" over the visible tail; consider suppressing the offer whenofferList.data.length === 100(a possibly-truncated page), matching the fail-closed posture thestackUnknownpath already takes.Readability
src/lib/error-summary.ts:50:stripGitPrefixnow stripsgh:too; rename tostripToolPrefix(both call sites are infirstMeaningfulLine, lines 59 and 61) so the name matches the generalized doc comment.src/features/pulls/RemotePrView.tsx:2680+ (BaseBranchField) re-implementsSelectField(src/components/form/fields.tsx:173) verbatim, including thesizeToContentpopup sizing (alignItemWithTrigger={false},w-auto min-w-(--anchor-width) max-w-[28rem]); extractingSelectField's presentational core into a form-agnostic component both use would keep the two from drifting.src-tauri/src/forge/bitbucket.rs:edit_pr(… target: Option<&str>)callsbuild_edit_body(… base: Option<&str>)for the same value that every other arm callsbase; rename the parameter tobaseand update theedit_prdoc line that says "whentargetis given".Recorded decisions (acknowledged, not re-flagged)
Notes 5–6 (a timed-out paginated stacks probe now yields no badges rather than a partial first page), note 11 (
stack-chains.tsuntested — no frontend runner; the Rust twininfer_mr_stackscarries the tested rules), and note 13 (edit-dialog base picker offers local branches only) all remain recorded calls; nothing in the diff contradicts them.Posted by GitDesktop — AI output, verify before acting on it.
Related
Tickets:
#10Tickets:
#12Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo genuinely exploitable security vulnerabilities in these changes: the new
gh/glabwrites go through argv (no shell) with values carried as-f key=valueraw fields (the project's established guard against gh's@-magic file read), the one new-Fuse interpolates only formattedu64s, Bitbucket'sdestinationrides aserde_json::json!body, all three new MCP tools callensure_remote_write()consistently with the existing destructive-annotated forge tools (and the permission copy was updated to disclose them), and the new frontend surfaces render forge-supplied strings as escaped JSX text with the destructive dissolve behind a confirm.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Re the AI review — all five items land in the next push, plus one addition its findings prompted:
isCrossRepositoryjoins the list projection with a serde alias so the wire stayscrossRepository;detectStackOfferexcludes cross-repo rows outright, and both directions of the alias are pinned by a test (a silent name mismatch would have marked every fork PR same-repo). Class boundary, on record: GitLab's inference shares the name-collision hazard but its MR list carries no project ids — display-only badges there, deferred with a home in the backlog.detectStackOffernow fails closed at the page limit via a shared exported constant (the guard and the list fetch read the same value).stripToolPrefixrenamed as suggested.SelectControlextracted fromSelectField's presentational core;SelectFieldis now a thin form-bound wrapper and the edit dialog's base picker uses the same component — no more parallel markup to drift.base(the URL local isapi_basenow).stackUnknownarm stays covered in spirit ("a chain it can't see whole") rather than by name — it's an internal probe failure, not a property of the repo. The changelog fragment deliberately keeps benefit-level wording.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedRe-reviewed against the current diff: every previous finding is addressed, and the fix hunks themselves are clean apart from one accessibility gap in the offer preview and a small type-duplication nit. Nothing blocking.
Resolved since last review
PR_LIST_FIELDSnow requestsisCrossRepository(github/pr.rs:1646),PrInfo.cross_repositorycarries the serde alias plus camelCase wire name (:543), every literal construction site sets it (from_bb_pr,from_glab_mr,rest_pull_to_pr_info, both test builders),types.ts:1489mirrors it, anddetectStackOfferdrops those rows alongside the state filter with the doc's rule list extended. Both alias directions and the projection are pinned by new tests.offerIdentityis exported fromstack-chains.ts:44and drives bothStackOffer'slastKey(StackSection.tsx:325) and the parent's write-reset key (RemotePrView.tsx:565-572), so the two identities can't diverge.STACK_OFFER_PAGE_LIMITis the single source for both theusePrListlimit (RemotePrView.tsx:498) and the fail-closed guard (stack-chains.ts:100), with the reasoning corrected to the stronger "a partial stack would succeed silently" case.stripGitPrefixnaming — renamed tostripToolPrefixwith both call sites (error-summary.ts:59,61) and the doc comment updated.BaseBranchFieldduplicatingSelectField—SelectControlextracted;BaseBranchFieldnow calls it withsizeToContent, and I verified the trigger keepsclassName="w-full"(fields.tsx:217) and the popup keepsalignItemWithTrigger={false}+w-auto min-w-(--anchor-width) max-w-[28rem], so the picker is visually unchanged. The now-deaduseId/Label/Select*imports are gone fromRemotePrView.tsxwith no remaining uses, andisNativeStackhas exactly one definition (stack-chains.ts:14) with every importer updated.targetparameter — renamed tobase, the URL local renamedapi_base, theedit_prdoc line reworded, and the sole call site (forge/mod.rs:1577) updated.Accessibility
src/features/pulls/StackSection.tsx:384-409(StackOfferpreview): the preview scroller ismax-h-48 overflow-y-auto(12rem) over plaindivrows (~29px each:py-1.5+text-xs+ border), so a chain of 7+ members overflows with no focusable descendant anywhere inside. Its sibling at:203gets away with the same cap because its rows are<button>s wired tolistKeyboardNav, so focus scrolls it; here a keyboard-only user cannot reach the hidden members — and this preview is the pre-write safety check the whole offer is built around (the auto-focused Confirm at:416is the very next stop). Chromium 127+ makes scrollers focusable by default, so this reproduces specifically on the macOS WKWebView build. Fix: puttabIndex={0}on the scroller at:387plus an accessible name, e.g.role="group"witharia-label={offer.kind === "create" ? "Pull requests to stack, bottom to top" : \Pull requests to add to stack #${offer.stackNumber}`}— DOM order puts the new tab stop before Confirm, so the expand-timeconfirmRef.current?.focus()still wins the initial focus. Knock-on: the comment at:384-386` currently asserts "nothing here is selectable, so it takes no keyboard nav" — reword it to say the container is focusable purely so its overflow is scrollable by keyboard, while the rows stay non-interactive.Readability
src/components/form/fields.tsx:256-262:SelectField's prop type is a hand-copied duplicate ofSelectControl's, so a prop added to the control is silently missing from the bound wrapper; derive it instead —}: Omit<ComponentProps<typeof SelectControl>, "value" | "onValueChange">) {— addingtype ComponentPropsto the existingreacttype import.Copilot's only remaining comment (stale stack-write error surviving an offer change,
StackSection.tsx:335) is fixed in the current diff by the sharedofferIdentitykey; no need to act on it.The author's reply records that a reset during a genuinely in-flight write clears the pending flag without aborting the request, and that the settled write still reports — that remains a recorded decision and I'm not re-flagging it. Documentation is complete across all four surfaces (README's GitLab/MCP/stacked-PR paragraphs,
capabilities.ts, the help guide's GitHub/GitLab/Bitbucket/MCP sections, andchangelog.d/added-stack-management.md);site/src/pages/index.astrohas no stacksFeatureRowto go stale.Verdict: no blocking issues — remaining items are non-blocking; merge when ready
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Re the re-review — both remaining items land in the next push:
tabIndex={0},role="group", an offer-specificaria-labelnaming the create/add intent) so a 7+-member chain is keyboard-reachable on WebKit too; the rows stay non-interactive, DOM order is unchanged so the expand-time focus still lands on Confirm, and the comment above it now states the real constraint (focusable purely for keyboard-scrollable overflow — this preview is the pre-write safety check). Matches the existing focusable-scroller pattern inPrTasksSection.SelectFieldprop type derived viaOmit<ComponentProps<typeof SelectControl>, "value" | "onValueChange">— a prop added to the control can no longer go silently missing from the bound wrapper.One honesty note carried forward: the WebKit focusability difference is applied as documented behavior — the dev target here is Chromium-based, so the macOS keyboard pass stays on the live-check list.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedBoth items from the last round are fixed, and the fix hunks are clean; the only thing left is a one-line styling consistency nit on the new tab stop.
Accessibility
src/features/pulls/StackSection.tsx:397-398: the new focusable scroller carries no focus styling (className="mt-1.5 max-h-48 overflow-y-auto border"), so it falls back to the UA ring, unlike the repo's three other focusable container divs which all pin it explicitly —BitbucketDefaultReviewersSection.tsx:73,CollaboratorsSection.tsx:158,218,GitLabMembersSection.tsx:142, eachoutline-none focus-visible:ring-1 focus-visible:ring-ring. Match them:className="mt-1.5 max-h-48 overflow-y-auto rounded-md border outline-none focus-visible:ring-1 focus-visible:ring-ring"(droprounded-mdif the square border is intended here).Resolved since last review
StackSection.tsx:389-398now wraps the rows inrole="group"+tabIndex={0}with an offer-specificaria-label(create/add branches narrow cleanly offoffer.kind, sooffer.stackNumberis well-typed in the add arm), the rows stay non-interactive, DOM order is unchanged so the expand-timeconfirmRef.current?.focus()still wins, and the comment above it now states the real constraint instead of the now-false "takes no keyboard nav".SelectFieldprop-type duplication —fields.tsx:255derives fromOmit<ComponentProps<typeof SelectControl>, "value" | "onValueChange">withtype ComponentPropsadded to the existingreacttype import; the destructured names still match the derived keys exactly,useId/ReactNoderemain used by the other fields, andSelectFieldpassingfield.state.valueintoSelectControl's requiredvalue: stringpreserves the oldvalue || nullbehavior (the falsy-to-null coercion now lives inSelectControl).Nothing further to raise — the rest of the diff is unchanged since the last round and re-verified as reported there.
Verdict: no blocking issues — remaining items are non-blocking; merge when ready
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Re the styling nit — accepted with a correction on the record: the three files cited as precedent don't exist in this tree (grep: no
BitbucketDefaultReviewersSection.tsx,CollaboratorsSection.tsx, orGitLabMembersSection.tsx). The repo's actual idiom — on the verytabIndex={0}precedent this scroller was matched to,PrTasksSection.tsx:101, and its interactive siblings — isoutline-none focus-visible:ring-1 focus-visible:ring-ring/50(with the/50opacity, not barering-ring). Applied in that form, square border kept to match this file's own sibling list. One line; rides the closing push.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThe only change since the last round is the one-line focus-ring class on the offer preview's scroller, which resolves the sole outstanding nit; the fix hunk introduces no collateral.
Resolved since last review
StackSection.tsx:398now carriesoutline-none focus-visible:ring-1 focus-visible:ring-ring/50. The/50opacity is the right call for this file: every focusable element insrc/features/pulls/uses it (PrTasksSection.tsx:101,ReviewThreads.tsx:632,843,LinkedIssuesField.tsx:218,395). One correction for the record only — the threerepo-settingsfiles cited as precedent last round do exist in this tree (BitbucketDefaultReviewersSection.tsx:73,CollaboratorsSection.tsx:158,218,GitLabMembersSection.tsx:142, all barering-ring); that doesn't change the fix, which correctly matches the closerfeatures/pullsidiom. No other class was touched, DOM order and therole="group"/aria-label/tabIndextriple are unchanged, so the expand-timeconfirmRef.current?.focus()still wins.Nothing further to raise — the rest of the diff is unchanged from the previous round and re-verified as reported there (spot-checked
usePrList(repo, enabled, state, limit, lens)against the new offer call site's argument order, andpresentError/onError/defaultBranchare all in scope at their new use sites inRemotePrView.tsx).Verdict: no blocking issues — remaining items are non-blocking; merge when ready
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Correction to my previous comment, for the record: the reviewer is right and I was wrong — all three cited files exist at
src/features/repo-settings/(BitbucketDefaultReviewersSection.tsx:73,CollaboratorsSection.tsx:158,218,GitLabMembersSection.tsx:142, barering-ring). My "no such files" claim came from a wrong path guess plus a truncated file-list grep — my error, not a reviewer fabrication. The applied fix is unchanged and unaffected:ring-ring/50per this file's own family (PrTasksSection,ReviewThreads,LinkedIssuesField), which the re-review confirms as the right call here.Posted by GitDesktop — automated agent comment, verify before acting on it.
Ticket changed by: theBGuy