fix(git,pulls,mcp,stash): correct base branch, merge SHA, stash toast
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
Four independent user-facing bugs, each traced to a place where the code assumed something the repository or the wire format didn't guarantee: that a remote is named origin, that serde's rename_all renames fields, that the MCP release path mirrors the in-app editor, and that a stash always happened. Each fix carries a changelog fragment; no other doc surface describes these behaviors.
git_default_branch in src-tauri/src/git/branches.rs to enumerate remotes via git::remote::git_remotes and read each refs/remotes/<remote>/HEAD, instead of hardcoding refs/remotes/origin/HEAD. A stable sort on r != "origin" keeps origin first and preserves git remote order for the rest, so a clone made with git clone -o <name> resolves.read_symbolic_ref in src-tauri/src/git/remote.rs from private to pub(crate) so the branch path can reuse it.main/master fallback.default_branch_resolves_a_clone_whose_remote_isnt_origin (uses a trunk source branch the local fallback could never produce), default_branch_prefers_origin_over_other_remotes (fixture asserts canonical is listed first, so the test actually discriminates), and default_branch_falls_back_to_local_names.rename_all_fields = "camelCase" to PrTimelineEventOut in src-tauri/src/github/pr.rs. rename_all on a tagged enum renames variant tags only, so Merged.commit_oid was reaching the TS mirror in src/lib/git/types.ts as undefined and a merged PR lost its merge commit silently.merged_timeline_event_wire_shape_is_camel_case, asserting the exact key set (actor, commitOid, date, kind) and that no commit_oid key ships.sync_release_updater_notes to src-tauri/src/mcp_server/write_forge.rs, giving the MCP update_release tool the same latest.json sync the in-app editor performs: GitHub-only (via forge::detect_non_github), and only when the release actually carries the asset. It reuses the pre-edit ReleaseDetails the tool already fetched, falling back to forge_release_view otherwise.notes_to_sync is captured before the preserve-current fallbacks consume args.notes, and empty/whitespace notes are filtered out — those skip --notes on the edit, so there is nothing to carry over.updater_manifest or updater_manifest_error strings, with each failure arm wording its own sentence so an unverifiable asset list is never reported as "no manifest".sync_updater_notes: Option<bool> argument to UpdateReleaseArgs (defaults to on), extends the tool description accordingly, and updates the gating test fixture.UPDATER_MANIFEST constant mirroring github::release's private one and the frontend's UPDATER_MANIFEST_NAME.ChangesPanel.tsx now reads the matched boolean from stashPaths.mutate's onSuccess and shows toast.info("Nothing to stash") when a pathspec matched nothing, rather than claiming a stash that was never created.gitStashPaths in src/lib/git/api.ts.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
73b288eView logs
Originally posted by: theBGuy
Context for reviewers — deliberate calls and disclosures, on the record before the ready-flip. The PR description covers the what/why; these are the calls a review would otherwise re-derive.
Deliberate calls
TagDetailView.tsx:154) and non-empty trimmed notes (:392). The second half is load-bearing:gh_release_edittrims and skips--noteswhen empty (github/release.rs:260-281), so an empty-notes sync would blank the live manifest while the release body stayed unchanged.notes_to_syncis captured fromargs.notesonly — never the preserve-current fallback — so the manifest can only ever receive the caller's own text.detect_non_githubpre-gate + a directgh_release_sync_updater_notescall (rather than routing throughforge_release_sync_updater_notes) is deliberate: the forge wrapper errors on GitLab/Bitbucket, and this path requires a silent skip — a successful edit must never be turned into an error by the sync. If wrapper routing is preferred, its error arms would need to become skips first.updater_manifest_errorfield), never a tool error — the edit already landed by then. The error text ships verbatim because it carries the recovery-file path for a clobbered manifest (--clobberis delete-then-upload; that parked copy is the caller's only recovery route).UPDATER_MANIFESTis duplicated inwrite_forge.rswith a cross-reference comment — the canonical const is private togithub/release.rs, which this batch deliberately doesn't touch. Making itpub(crate)is a recorded backlog rider.git_default_branchstays local-refs-only, no network — it has noStateparam and runs in read paths; theremote set-head --autonetwork write remains exclusively ingit_remote_default_branch. A hand-added remote whose HEAD symref was never written intentionally falls through to the localmain/masterfallback (documented on the function).git_stash_allreturns void; giving it a zero-match signal is a different (and UI-unreachable) scope. Only the path-selection stash carries the boolean.toast.infofor "Nothing to stash" follows the repo's no-op-outcome idiom (BranchSwitcher.tsx:851).Disclosures (deferred with homes)
committed_base_ref(mcp_server/generate.rs:1774) is one step behind this fix: a non-origin clone now resolves a default branch but the remote-tracking-ref preference still probesrefs/remotes/origin/only, so it lands on the local branch there. Net improvement over the previousNone; the completion (exposing which remote answered) is a recorded backlog item, its own package.patch_updater_notesunit tests and code-trace; the GUI path it mirrors was E2E'd in [#135].api.tsappears with a one-line comment trim only; any local format noise on it is the standing Windows CRLF checkout artifact (--numstatshows 1/2;biome lintclean; CI checks out LF).cargo clippy --all-targets(stricter than CI's form) reports 2 pre-existingawait_holding_lockhits ingenerate.rstest code from [#135]'s settings-lock serialization — untouched here and outside CI's gate.content.ts:1141describe the in-app updater sync and remain accurate; the MCP tool surface isn't documented in the help guide; the timeline docs never claimed a merge sha).Verification at open: full
cargo test929/929 · both CI clippy forms clean ·pnpm buildclean ·tsc -bclean ·changelog:check11 fragments valid.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#135Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThe PR lands four independent fixes — non-origin default-branch resolution, the
rename_all_fieldsserde fix for the PR timeline's merge sha, an MCP updater-manifest sync mirroring the in-app editor, and a "nothing to stash" toast — plus targeted tests for the first two. I traced each: the serde fix is complete (commit_oidis the only multi-word field inPrTimelineEventOut, the TS mirror attypes.ts:1614already declarescommitOid?, and the GitLab/Bitbucket arms that also buildMergedare covered by the enum-level attribute), the default-branch rewrite is behaviourally equivalent for the origin case and the three new tests genuinely discriminate, and the stash boolean is typed throughuseRepoMutation<TArgs, TData>correctly. Nothing blocking; two things worth fixing before merge.Correctness & tool safety
src-tauri/src/mcp_server/write_forge.rs,update_releasetool metadata (description, lines 1082–1091;annotations(read_only_hint = false, destructive_hint = false), line 1092). The sync path now reached by default callsgh_release_sync_updater_notes, which uploads with--clobber; per that function's own doc (github/release.rs:466–472) gh implements--clobberas delete-THEN-upload, so a failed upload atrelease.rs:513–538leaves the release with nolatest.jsonat all, recoverable only from the parked temp copy. Concrete trigger: an agent callsupdate_release { tag, notes }on a release carrying a manifest (no opt-out passed, soargs.sync_updater_notes != Some(false)is true), the download+patch succeed, and the upload fails on a transient 5xx — the live updater manifest is gone. An MCP client decides whether to auto-approve from exactly these two fields, andmerge_pull_request(line 617) is annotateddestructive_hint = truefor the same "not trivially reversible" reason. Fix: append a clause to the description string, e.g.Syncing REPLACES the latest.json asset (delete-then-upload) — if the upload fails the release is left without a manifest and the patched copy's path is returned in updater_manifest_error., and make the annotation honest one of two ways: flip line 1092 todestructive_hint = true(accepting that plain title/state edits inherit the hint), or defaultsync_updater_notesto opt-in (.filter(|_| args.sync_updater_notes == Some(true))at line 1158) so thefalsehint stays true — the latter also needs the description's "passsync_updater_notes: falseto leave it alone" reworded to "passsync_updater_notes: trueto sync it", and the changelog fragmentchangelog.d/fixed-mcp-release-updater-sync.mdre-worded to match ("now can keep … in sync"). I'd take the first: opt-out matches the in-app editor's default and note 1's gate reasoning.Tests
src-tauri/src/mcp_server/write_forge.rs:1099–1107and:1158: thenotes_to_syncgate has no test. Both halves are load-bearing and both are pure logic: the.filter(|n| !n.is_empty())is the only thing standing between a whitespace-onlynotesargument and a blanked live manifest (the edit skips--notesatgithub/release.rs:278–281, so the release body stays put while the manifest would be overwritten with ""), and!= Some(false)is the caller's only escape hatch. The only test touched isall_write_tools_gated_on_allow_remote_write, which just addssync_updater_notes: Noneto the struct literal. Fix: extract the gate next tosync_release_updater_notesasfn updater_notes_to_sync(notes: Option<&str>, sync: Option<bool>) -> Option<String>(moving the current comment at lines 1099–1101 onto it, reworded for the helper), call it fromupdate_releaseaslet notes_to_sync = updater_notes_to_sync(args.notes.as_deref(), args.sync_updater_notes);— still before the fallbacks consumeargs.notes— and drop the now-redundant.filter(…)at line 1158 so theif let Some(sync_notes) = notes_to_syncreads plainly; then add a#[test]in the existingmod testscoveringNone → None,Some(" ") → None,Some("n") + Some(false) → None,Some(" n ") + None → Some("n"). This fits the repo's pure-logic Rust unit-test posture and pins the guard that note 1 identifies as the reason the second half of the gate exists.Nits
src-tauri/src/mcp_server/generate.rs:1769–1771: thecommitted_base_refdoc comment still saysgit_default_branch"returns the SHORT LOCAL name even when it derived it fromorigin/HEAD" — after this change it can derive from any remote's HEAD; change that phrase to "from a remote's HEAD". (The behavioural half — the remote-tracking preference still probingrefs/remotes/origin/only — stays a recorded deliberate deferral per note 9.)src-tauri/src/git/branches.rs:382–395: the overwhelmingly common single-originrepo now pays two git spawns instead of one, becausegit remotealways runs before the origin probe, anduseDefaultBranch(src/lib/git/queries.ts:3376–3381) has nostaleTimeso it refetches on mount/focus; probingrefs/remotes/origin/HEADfirst and listing remotes only on a miss keeps the fix and the one-spawn fast path — the "Origin first when present (a stable sort…)" comment then belongs on the remaining loop, not the sort.Recorded decisions I'm not re-raising: the duplicated
UPDATER_MANIFESTconst with its cross-reference comment (note 5, backlog rider), the unfired live round-trip (note 10), and the changelog-fragments-only doc call (note 13) — I confirmed independently thatREADME.md:421–423andcontent.ts:1141–1143describe the in-app editor and stay accurate, that the README's MCP section documents the write ladder only categorically ("cut releases"), that the help guide makes no origin-only default-branch or always-creates-a-stash claim, and thatCHANGELOG.md's## [Unreleased]is correctly left untouched; all four fragments matchchangelog.d/README.md's format.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo exploitable security issues found in these changes.
Notes on what I checked and why each risky-looking path is safe:
git_default_branchnow interpolates remote names fromgit remoteintorefs/remotes/{remote}/HEAD. Names are passed as a single argv element togit symbolic-ref --quietviarun_git(no shell), always prefixed withrefs/remotes/, so a--leading name can't become a flag, andread_symbolic_refonly returns a target that starts with the same prefix — the same output shape the origin-only code already produced, so no new refspec-injection surface for downstream consumers.update_release→sync_release_updater_notespath is gated byensure_remote_write(), validates the tag (validate_tagrejects empty/leading--) insidegh_release_sync_updater_notes, andpatch_updater_notesonly replacesnotesafter asserting the Tauri manifest shape — platform URLs and signatures survive verbatim, so the update-channel integrity isn't weakened. It mirrors a capability the GUI already exposes, so it isn't a privilege escalation for an LLM caller.rename_all_fields, thepub(crate)visibility bump on an internal helper, the stash toast, and the comment trim carry no security-relevant sink.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-1 dispositions — all four findings verified against the code and accepted; the fix batch is applied and lands in the next push.
destructive_hint = true(matchingmerge_pull_request's precedent) plus the delete-then-upload clause in the description; the sync stays default-on/opt-out to mirror the in-app editor. One refinement beyond the suggested sentence: the clause is worded arm-neutral ("a failed upload can leave the release without a manifest;updater_manifest_errorthen carries the recovery details, including the patched copy's path when one could be parked") — the unconditional form would over-claim twice: the delete lives inside gh's clobber so a pre-delete failure leaves the manifest intact, andsave_updater_recovery_copyis best-effort (release.rs:450-464degrades toNone, no path in that arm). The GUI's own summary made the same arm-neutral call (TagDetailView.tsx:428-435). Related residual logged:release.rs:466-472's own doc carries the same unconditional "names its path" phrasing (pre-existing, outside this PR's scope) — backlog.updater_notes_to_sync(Option<&str>, Option<bool>) -> Option<String>extracted next to the sync helper, call-site filter dropped, and the exact four cases pinned (None→None, whitespace→None, opt-out beats real notes, trim asserted). 87/87 inmcp_server, 930/930 full suite.committed_base_refcomment (nit) — fixed ("derived it from a remote's HEAD"). The behavioral half stays deferred per context item 9, as you noted.refs/remotes/origin/HEADis probed first (single-origin repos back to one spawn); the remote listing runs only on a miss and sweeps the others ingit remoteorder. Semantics byte-identical — the three new tests pass unchanged.staleTimeonuseDefaultBranchdeliberately not touched (out of this PR's scope; noted).Sweep result from closing this round (deferred with a home, not this PR): the annotation-honesty class has siblings —
set_pull_request_assignees/set_issue_assigneesare documented REPLACE-the-full-set (an empty list clears) yet annotateddestructive_hint = false, andedit_labels' remove arm is the same shape. Filed in the backlog as its own audit-and-fix batch.Also in the next push: Copilot's doc-length nit (the sync helper doc, 8 → 6 lines — thread replied + resolved).
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedAll four round-1 findings are fixed and I re-verified the fixes as new code: the origin fast path in
git_default_branch(branches.rs:390–406) is semantically identical to the loop it replaced —read_symbolic_refswallows the non-zerosymbolic-refexit intoOk(None)(remote.rs:376–397), so the?onremote_head_branchcan't turn a missing origin into an error, and the three tests still discriminate (thecanonical-sorts-first fixture still rules out "first listed wins"). Theupdate_releasemetadata now matches behavior, and the extracted gate is exercised.Resolved since last review
write_forge.rs:1104–1109:destructive_hint = trueplus the delete-then-upload clause. I checked the wording against the code it describes:gh_release_sync_updater_notes(release.rs:513–538) only reaches the recovery arm on upload failure, andsave_updater_recovery_copy(release.rs:450–464) degrades toNone, so "can leave the release without a manifest" / "when one could be parked" is accurate rather than over-claiming.updater_notes_to_sync(write_forge.rs:392–407) with the four cases pinned at :1777–1796; the call-site.filter(…)is gone andif let Some(sync_notes) = notes_to_syncat :1168 reads plainly.notes_to_syncis still computed at :1117, beforeargs.notesis consumed by the preserve-current fallbacks at :1141–1144, so the manifest can still only ever receive the caller's own text. Doc claims on the helper ("the edit trims and skips--notes") check out against release.rs:260–281.committed_base_refdoc — generate.rs:1770 now says "from a remote's HEAD".git remoteorder" comment went with the sort.sync_release_updater_notesis addressed — the doc is 6 lines at write_forge.rs:409–415.Leftover polish (non-blocking)
write_forge.rs:230–234— thesync_updater_notesfield doc says "Defaults to true; set false to leave the manifest alone" but never states the sync only fires whennotesare supplied; an agent reading only the parameter schema can passsync_updater_notes: truewith nonotesand get a silent no-op. Append: "Only applies whennotesare given — without them the manifest is left alone."ChangesPanel.tsx:625–626— "the files were already gone by the time git ran" is narrower than the boolean:git stash pushalso no-ops when the selected paths still exist but no longer carry changes. "…so no stash exists to report — the selection no longer had changes when git ran" covers both.Nothing further to raise.
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
Round-2 dispositions — both polish items verified and fixed, held as the closing batch (comment/doc-only, so they ride one final push rather than buying their own round):
sync_updater_notesfield doc now states the coupling: "Only applies whennotesare given — without them the manifest is left alone." Verified against the gate:updater_notes_to_syncreturnsNonewithout notes, sosync_updater_notes: truealone is indeed a silent no-op.ChangesPanelcomment now reads "the selection no longer had changes when git ran" — checked against the source of the boolean (git_stash_pathsreports false only when git says "No local changes to save" for those paths), which covers both the files-gone and present-but-unchanged cases, as you noted.Nothing further open on our side. After this batch lands, the board is: both rounds' findings closed against on-record dispositions, Copilot approved (thread resolved), security audit clean, CI green on every head so far.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedOnly two comment/doc lines changed since the last round; both land as described and I re-verified them against the code they describe. The rest of the diff is unchanged and still checks out —
read_symbolic_refis nowpub(crate)and itsOk(None)-on-non-zero contract (remote.rs:376–397) keeps the origin fast path ingit_default_branchfrom turning a missing origin into an error,git_remotes(remote.rs:267–275) returns the plainVec<String>the sweep expects, andtypes.ts:1614already declarescommitOid?: string, so therename_all_fieldsfix aligns the wire with the mirrorPrTimeline.tsx:154already reads. Nothing blocking.Readability
write_forge.rs:230–233,sync_updater_notesdoc: the appended sentence repeats the prior clause ("set false to leave the manifest alone" / "the manifest is left alone"). Fold them: "Defaults to true, and only applies whennotesare given; set false to leave the manifest alone."Resolved since last review
write_forge.rs:230–233— the field doc now states the coupling withnotes, and it matches the gate:updater_notes_to_sync(write_forge.rs:399–407) returnsNonefornotes == Noneregardless ofsync, sosync_updater_notes: truealone is indeed the silent no-op the doc now warns about.ChangesPanel.tsx:625–626— the comment now reads "the selection no longer had changes when git ran", which covers both the files-gone and present-but-unchanged cases that makematchedfalse; theonSuccess: (matched)callback still types cleanly offuseStashPaths→gitStashPaths'sinvoke<boolean>(queries.ts:3438–3442, api.ts:798–799).Nothing further to raise.
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
Round-3 disposition — the readability nit is accepted and deferred with a home: the suggested fold ("Defaults to true, and only applies when
notesare given; set false to leave the manifest alone.") is correct, but a comment-only push would spend a full review round on it. It rides the already-filed backlog batch that audits thedestructive_hintannotations onset_pull_request_assignees/set_issue_assignees/edit_labels— that batch touches exactly this file's tool docs, so the fold lands with it.With that on record, both rounds since the last substantive fix have returned merge-when-ready with no blocking or should-fix findings; all inline threads are resolved. Pending only the rust matrix completing on this head, this PR is merge-ready.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedFour independent fixes (non-origin default-branch resolution, MCP release→updater-manifest sync,
Merged.commit_oidwire shape, stash zero-match toast), each with a changelog fragment. The logic checks out:read_symbolic_ref(remote.rs:376–397) returnsOk(None)on a non-zerosymbolic-ref, so the new origin-first/sweep path ingit_default_branchnever turns a missing origin into an error;commit_oidis the enum's only multi-word field (pr.rs:2573–2627), sorename_all_fieldschanges exactly one key and matchestypes.ts:1614/PrTimeline.tsx:154; the sync gate mirrorsedit_release's own trim-and-skip (release.rs:260–281), so an empty-notes sync can't blank a live manifest. Nothing blocking; two nits only.Recorded decisions I'd otherwise raise, acknowledged rather than re-flagged:
committed_base_ref(generate.rs:1778) still probesrefs/remotes/origin/only, so a-o upstreamclone now lands on the local branch — a net improvement over the previousNone, deferred with a home; and the changelog-fragments-only doc call — I checked README:421 and helpcontent.ts:1141(in-app updater sync) plus the guide's Stash (475–486) and default-branch (412–451) copy, and none of them state a claim this PR invalidates.Readability
write_forge.rs:230–233(UpdateReleaseArgs::sync_updater_notesdoc): "set false to leave the manifest alone" and "without them the manifest is left alone" say the same thing twice. Fold to one sentence:/// Defaults to true, and only applies whennotesare given; set false to leave the manifest alone.branches.rs:371–380(remote_head_branch): it rebuilds therefs/remotes/{remote}/HEAD+refs/remotes/{remote}/pair thatgit_remote_default_branchalready builds inline (remote.rs:345–346), and living inbranches.rsis whyread_symbolic_refhad to widen topub(crate). Movingremote_head_branchintoremote.rsaspub(crate)besideread_symbolic_ref, havinggit_remote_default_branchcall it for its first probe, and revertingread_symbolic_refto private would fold both together;branches.rsthen callscrate::git::remote::remote_head_branchat both call sites (lines 393 and 403).Copilot's only open point — the
sync_release_updater_notesdoc being over the comment convention — was trimmed and now sits at the "~6 lines for multi-constraint blocks" ceiling; no action needed.Nothing from the previous round is verifiably fixed in this diff (its one open nit, the field-doc repetition, is re-listed above), so there is no resolved list this round.
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
Note for the record: the 04:36 review above is a duplicate run against the pre-push head (
3410401) — an app bug caught by this dogfood, now filed: a stale poll snapshot right after the push re-fired the sync for the old head (its analysis cites pre-closing-batch line numbers). Dispositions on its two items so nothing reopens as new:sync_updater_notesdoc fold — already accepted and deferred with a home in the round-3 disposition above (rides the annotation-audit batch).remote_head_branchintoremote.rs(lettingread_symbolic_refgo private again and sharing the first probe withgit_remote_default_branch) — reasonable polish, accepted-as-deferred: it rides the already-filedcommitted_base_reffollow-up batch, which reworks exactly this resolver area.Board state stands: merge-ready — CI green on
73b288e(all 7 checks), two consecutive merge-when-ready verdicts on the real heads, all threads resolved, remaining polish deferred on record.Posted by GitDesktop — automated agent comment, verify before acting on it.
Ticket changed by: theBGuy