Originally created by: theBGuy
Originally owned by: theBGuy
When git refuses a pull, a branch update, or a branch switch because it would overwrite uncommitted changes, the app currently dead-ends in a toast and leaves the user to stash by hand. This adds a stash → run → reapply compound behind that refusal: one click sets the changes aside (untracked files included), runs the operation, and puts them back on the other side — reporting exactly where the changes ended up in every failure mode, including the conflicted-reapply case git's own --autostash silently swallows.
src-tauri/src/git/autostash.rs with the git_pull_autostash, git_merge_autostash, and git_switch_autostash commands, each acquiring repo_lock once and driving the compound with lock-free runners only (re-entering run_git_mutating under the held lock would deadlock).AutostashOutcome enum, which distinguishes NothingStashed, StashedOnly, Reapplied, ReapplyConflicted, OpFailedRestored, and OpFailedStashKept — so a conflicted pop or a kept stash is reportable rather than collapsed into a bare exit code. Helpers cover autostash_push (parsing git's "No local changes to save" stdout line), autostash_pop, has_unmerged, refuse_mid_op, and failure_text (git writes conflict output to stdout with an empty stderr).src-tauri/src/git/remote.rs: run_git_with_creds_once is the new lock-free, raw-output half (keeping the one-shot ambient-credential retry on auth-class failures), and run_git_mutating_with_creds now wraps it with the repo lock, the transient index.lock retry, and AppError::Git shaping — preserving the existing contract for fetch/pull/push callers.op_state from git_op_state in src-tauri/src/git/ops.rs so the mid-operation refusal gate and the conflict banner read the same marker files.src-tauri/src/lib.rs and the module in src-tauri/src/git/mod.rs.AutostashOutcome union and the gitPullAutostash / gitMergeAutostash / gitSwitchAutostash invokers to src/lib/git/api.ts, mirroring the Rust variants.usePullAutostash, useMergeAutostash, and useSwitchAutostash to src/lib/git/queries.ts (whole-repo invalidation, matching their plain counterparts), and gives useUpdateFromUpstream a new dirty-blocked outcome that returns the already-resolved upstream/<branch> ref instead of throwing, so retrying costs no second fetch.isDirtyTreeRefusal and the DIRTY_TREE_MARKERS table to src/lib/error-summary.ts — the verbatim merge/checkout/rebase refusal strings, kept deliberately disjoint from CONFLICT_MARKERS so real conflicts keep their existing banner recovery.src/features/repository/useStashReapplyRecovery.tsx, the shared classify → prompt → retry → report hook, plus reportAutostashOutcome, which maps each outcome to its toast and keeps the raw git output one click away via a Details/Copy action.src/features/repository/StashReapplyDialog.tsx — the presentational prompt with the "Always stash and reapply" checkbox and Enter-completes focus on the primary.src/features/repository/SyncControls.tsx for pull (recovering on the refusal itself, never pre-flighted) and for the new dirty-blocked update-from-upstream outcome; wires src/features/pulls/LocalPrView.tsx for in-place branch updates, gated on the head being the current branch since the throwaway-worktree path is always clean.src/features/repository/BranchSwitcher.tsx to run a single switch compound for both checkbox states, and re-opens the choice with a hint when "bring changes along" is itself refused; src/features/repository/SwitchWithChangesDialog.tsx gains the hint, reapply, and onReapplyChange props with a "Reapply after switching" checkbox.src/features/repository/ConflictBanner.tsx so unmerged paths with no operation to continue or abort (a conflicted stash pop) point at the changes list instead of showing a bare count.autoStashOnPull and reapplyStashOnSwitch to AppSettings and DEFAULT_SETTINGS in src/lib/settings/api.ts (both off by default).src/features/settings/GeneralSection.tsx.site/src/data/capabilities.ts gains the capability under Rewrite & recovery.src/features/help/content.ts gains a Stash and reapply guide section and extends the branch-switching section for the new Reapply after switching choice.changelog.d/added-stash-reapply-recovery.md.Relates to [#150]
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
794cc72View logs
Originally posted by: theBGuy
Context for reviewers — deliberate calls, evidence, and disclosures, numbered for reference. (Posted while draft so it precedes the first review.)
What this is. One-click stash → run → reapply recovery when uncommitted changes block a sync operation (#150). A dirty tree that doesn't overlap the incoming change behaves exactly as before — the machinery only engages on git's refusal.
Deliberate calls:
--autostash. Measured on git 2.51.1:--autostashexits 0 even when the reapply conflicts (the only signal is a printed line; no MERGE_HEAD/rebase state exists, so no conflict UI can key off it), it never stashes untracked files (our stash is--include-untracked), andswitch/checkouthave no autostash at all.DIRTY_TREE_MARKERSare measured strings (git 2.51.1, cited at the definition), deliberately disjoint fromCONFLICT_MARKERSso real conflicts keep their existing banner recovery.git switchemits "overwritten by checkout" — measured, not assumed.run_git_with_creds_oncereturns raw output (non-zero exit ≠Err): a conflicted merge/pull writes its report to stdout with an empty stderr (measured), and error-shaping at that layer would hand the UI empty payloads.run_git_mutating_with_credsre-shapes toAppError::Git; all 10 existing call sites keep their exact contract (each verified).run_git_mutating_with_creds: one continuous repo-lock hold across the injected + ambient attempts (previously two acquisitions), with the index.lock retry wrapping the pair. Worst-case wall clock is structurally unchanged (both old and new are two runner invocations deep per attempt pair).ReapplyConflicted.conflicted/OpFailedStashKept.inProgressexist because each variant covers two user-distinguishable states — an untracked-file pop abort leaves no unmerged paths (nothing in the changes list to point at), and a failed restore-pop leaves no banner to continue/abort. Copy branches on them; the wire shape is pinned by a serialization test over all variants.StashedOnly): one continuous repo-lock hold with the stash popped back if the switch fails. The old two-step path left the stash stranded on a failed checkout — that delta is an intentional upgrade, not parity.!opbranch extension is generic on purpose — it also improves manualgit stash popconflicts, which previously showed a bare count.reset --hard; a stranded autostash is recoverable via the stash list and the orphaned-stash rescue UI.Verification: cargo 1001 passed / 0 failed (20 autostash tests incl. the wire-shape pin), clippy clean,
pnpm build+ site build green. Live dogfood on a scratch origin+clone: tracked and untracked refusals, bothReapplyConflictedarms, preference-on no-dialog recovery, non-overlap fast path, settings round-trips, switch reapply end-to-end, Enter/Esc keyboard paths.Disclosures (deferred, each with a home):
git_stash_paths_coreguards unmerged entries but not a fully staged mid-merge resolve, andgit_stash_all_corehas no mid-op guard at all. The new compounds guard both. → backlog.file://remotes); the first dirty pull against an authenticated remote covers it.OpFailedStashKept's in-progress arm is unit-tested; its banner is pre-existing UI.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#148Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedSummary
Adds an app-level stash → run → reapply compound (
git_pull_autostash/git_merge_autostash/git_switch_autostash) plus the UI choreography that offers it when git refuses a pull, branch update, or switch over uncommitted changes, and refactorsrun_git_mutating_with_credsinto a lock-freerun_git_with_creds_once+ a locking wrapper so the compounds can run under a single repo-lock hold.The design is sound and I found nothing blocking: the lock discipline is correct (every inner step uses the lock-free runners,
refuse_mid_opgates both the unmerged-index and the staged-mid-op case),settlenever drops the stash, the plain-command error shape is preserved for the nothing-stashed path, and the outcome wire shape is pinned by a serialization test. Docs are covered across all four surfaces (README,capabilities.ts, help guide, changelog fragment).Correctness & feedback
src/features/repository/SyncControls.tsx:370(Pull button icon). The spinner is still gated onpull.isPendingalone, but the recovery compound is a second, separate mutation. With Automatically stash and reapply on pull on, clicking Pull runs: plain pull fails fast (git refuses locally, no network) →pullAutostashruns a realgit pullunderNETWORK_TIMEOUTwith no dialog open — so for the whole network round-trip the button group is merely flat/disabled (busyincludesrecovery.pending) with no progress indicator and no toast. The confirm-prompt path is fine (the dialog holds its own spinner), but the auto path is exactly the case the preference makes routine. Fix:{pull.isPending || recovery.pending ? <Spinner data-icon="inline-start" /> : <ArrowDownIcon data-icon="inline-start" />}.recovery.pendingin this component also covers the update-from-upstream recovery, which is launched from the same button group's caret menu, so the Pull button is the right place for it — no second call site needed.Recorded decisions I'm not re-raising: the branch menu's plain merge/rebase actions staying unwired to the recovery (note 13 — the same
DIRTY_TREE_MARKERSfire there, so the inconsistency is real but deliberate and backlogged); the help guide'sSettings & updatesGeneral enumeration (src/features/help/content.ts:1898) not listing the two new toggles (note 16 — and that bullet already omits auto-fetch, so it isn't claiming exhaustiveness); the real-HTTPS credential pull path being contract-verified rather than live-run (note 15).Tests
src/lib/error-summary.ts:83(DIRTY_TREE_MARKERS). The entire feature triggers off six hand-measured English strings, and nothing in CI notices if git rewords one — the failure mode is silent: the recovery simply stops being offered and users get today's dead-end toast. There's no frontend test runner, but the Rust suite already provokes these exact refusals with real git; add a test inautostash.rsthat runs a blockedpull(tracked and untracked overlap), a blockedpull --rebase(unstaged and staged-index variants), and a blockedswitch(tracked and untracked), asserting eachstderr.to_lowercase()contains the corresponding literal. Keep the literals duplicated in the test on purpose — that duplication is the canary — and add a comment onDIRTY_TREE_MARKERSpointing at the test so the two lists stay paired.src-tauri/src/git/remote.rs:106— nothing pins the refactoredrun_git_mutating_with_creds' hand-builtAppError::Git { code, stderr }shaping; one real-repo test with an emptycredslice and a failing sub-command (e.g.&["merge", "nope"]) assertingAppError::Gitwith git's own code/stderr would guard the contract the 10 existing call sites rely on.Nits
README.md, Stash and reapply bullet: "one git refuses outright leaves them safely stashed" is missing a word — "one that git refuses outright…" (the changelog fragment already has the correct phrasing).src/features/repository/useStashReapplyRecovery.tsx:36-55—stderrDetailsre-implementstoastError's Details/Copy action builder verbatim (src/lib/toast.ts:14-29); export the action builder fromtoast.ts(taking anErrorPresentation) and call it from both so the two can't drift.src-tauri/src/git/autostash.rs,refuse_mid_op— the unmerged-index arm says "Can't stash while a merge conflict is in progress" even when the unmerged entries come from a rebase or cherry-pick (that arm is reached first); "…while a conflict is in progress" would cover all three, matching the op-state arm below it.src/features/repository/SwitchWithChangesDialog.tsx:83,88—stashPending/bringPendingare effectively unobservable: both handlers callsetSwitchTarget(null)before starting the mutation, so the dialog is already closed by the time either flag flips. Either drop the props or keep the dialog open until the compound settles (the wayStashReapplyDialogdoes).src/features/settings/GeneralSection.tsx:114— the label "Automatically stash and reapply on pull" under-sells the toggle, which also governs Update from upstream and Update branch from (its own description says so); "…on pull and branch updates" would match. Worth settling now sinceautoStashOnPullis a persisted key that's awkward to rename later.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo genuinely exploitable security vulnerability is introduced by these changes.
What I checked and why it clears: the three new Tauri commands in
src-tauri/src/git/autostash.rsare the only new privileged entry points, and every caller-supplied argument reaching an argv is guarded —modeis mapped to a closed set of literal flags (--rebase/--no-rebase/--ff-only), andbranch/name/remotego throughbranches::validate_ref_name, which rejects empty, leading-(option injection), and* ? [ : \ <space>plus ASCII controls; theswitch --track {remote}/{name}argv is byte-identical in shape to the pre-existinggit_checkout_remote_branch, andmergehere uses a stricter validator thangit_merge_core'svalidate_branch_arg.run_git_rawspawns viatokio::process::Commandwith an argv array (no shell), so no interpreter is involved. Theremote.rssplit intorun_git_with_creds_once/run_git_mutating_with_credspreserves the auth-retry gate (is_auth_class_failureon a non-zero exit) and the injected-centries arecredential.https://<host>.helper=<abs path>— no token material on the command line — so the newAutostashOutcome.stderr/stdout-fallback payload surfaces no secret the plainAppError::Git { stderr }path didn't already. On the frontend,stderrreaches only React text nodes andpresentError/toast/clipboard — nodangerouslySetInnerHTML, no URL-scheme sink; the settings spread merges keys the code fully controls into a validated shape.Copilot's
remote.rs:93claim that&args.iter().map(String::as_str).collect::<Vec<_>>()is a borrowed temporary dropped across.awaitis wrong: argument temporaries live to the end of the enclosingletstatement, and the diff shows this is the identical expression the pre-change code already awaited throughrun_git_mutating. Not a defect, and not security either way.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 dispositions (GitDesktop AI review + security audit). Everything below is applied in the tree and rides the next push, alongside Copilot's accepted dialog fix.
Should-fixes — both fixed.
pull.isPending || recovery.pending, your prescribed mechanism — verified thatrecovery.pendingORs both compounds, so the update-from-upstream recovery is covered from the same button group. One correction to the rationale for the record: the update arm deliberately re-merges with no second fetch (that's whydirty-blockedcarries the resolved ref), so "runs a real network pull" is true only of the pull arm; the code comment states the no-dialog rationale without the network claim. Known cosmetic edge, on the record: with the preference on, an update recovery spins the Pull button rather than the caret menu that launched it — the group shares one button row and the caret has no spinner slot; judged acceptable.refusal_stderr_still_matches_the_frontend_markers(autostash.rs). All six literals were re-derived by measurement inside the test and matchedDIRTY_TREE_MARKERSexactly; they're duplicated in-test deliberately (the duplication is the canary), andDIRTY_TREE_MARKERSnow carries the pairing pointer. Two scope notes: four of the six cases are provoked via plainmerge/switch(measured:pull --ff-only/--no-rebasereach the same merge machinery and emit identical lines, which keeps those cases network-free — the two rebase variants use a real bare-origin clone), and the canary pins markers-vs-git, not the disjointness-from-CONFLICT_MARKERSclaim, which remains comment-asserted.run_git_mutating_with_credscontract test (your test nit): addedmutating_with_creds_surfaces_gits_own_code_and_stderr— empty cred slice, failing merge, assertsAppError::Gitwith git's own exit code and stderr (measured:merge: nonexistent-ref-xyz - not something we can merge).Nits — all five fixed.
stderrDetailsduplication —errorToastAction(presentation)is now exported fromtoast.tsand used by bothtoastErrorand the hook; behavior parity verified (same Details/Copy selection, dialog wiring, clipboard fallback; repo-wide grep shows no other hand-built instance).git_stash_paths_core's identical sibling guard in ops.rs carried the same "merge conflict" wording; both now read "while a conflict is in progress".bringPending/stashPendingprops — removed from the dialog and call site (they were unobservable before this PR too: both handlers close the dialog before their mutation starts); the dialog's doc comment updated to match.Security audit: acknowledged, no action needed — and it independently settled Copilot's remote.rs lifetime claim with the same reasoning used to decline it on the thread.
Verification on the batch: cargo 1003 passed / 0 failed, clippy
-D warningsclean,tsc -bclean,pnpm buildgreen, scoped biome clean on every touched file.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedBoth should-fixes and all five nits from the last round are applied and correct in the current diff; the fix hunks themselves (the marker canary, the
errorToastActionextraction, the prop removal, the guard-message sweep) are clean, and I found no new problem in them.Resolved since last review
SyncControls.tsx:370now gates onpull.isPending || recovery.pending, so the no-dialog auto path shows progress;recovery.pendingcovers both compounds in that hook instance, so the caret-launched update recovery is covered from the same button.refusal_stderr_still_matches_the_frontend_markers(autostash.rs) provokes all six refusals with real git and asserts the literals;run_git_rawpinsLC_ALL=C, so the lower-cased matching is locale-stable, and thepull --rebaseunstaged/staged split is correctly staged (b.txt is tracked from the base commit, thengit added for the index variant).DIRTY_TREE_MARKERScarries the pairing pointer back.run_git_mutating_with_credscontract test —mutating_with_creds_surfaces_gits_own_code_and_stderrpins code + git's own stderr;AppState,temp_base, andinit_repoare all in scope in that test module.stderrDetailsduplication —errorToastActionis exported fromtoast.tsand used by bothtoastErrorand the hook; a repo-wide grep foruseErrorDialog.getState().open/label: "Details"now finds onlytoast.ts. The doc comment moved with the extracted logic andtoastErrorkept an accurate one-liner.autostash.rs:80and the siblingops.rs:626; no stale copy of the old wording survives anywhere (conflict is in progressmatches exactly those two lines).bringPending/stashPending— removed from both the dialog and theBranchSwitchercall site, and the dialog's doc comment updated to match;checkout/checkoutRemote/stashAllall still have live references, so nothing went unused.GeneralSection.tsx:114, README, the changelog fragment, andhelp/content.ts(the help copy is line-wrapped, so a naive grep for the phrase misses it — verified against the diff).SwitchWithChangesDialogtemplate-literal — fixed: the reapply arm is JSX interpolation now, so a nulltargetrenders empty during the close transition.Copilot's
remote.rs:93lifetime claim is a false positive and needs no action: theVec<&str>is an argument-position temporary, so it lives until the end of the enclosinglet out = …;statement and outlives the.await.Recorded decisions still standing, not re-raised: the branch menu's plain merge/rebase actions remain unwired to the recovery (note 13, backlogged) —
useMergeBranchatBranchSwitcher.tsx:749is still the only such call site; and the real-HTTPS credential pull path is contract-verified rather than live-run (note 15).Leftover polish (non-blocking)
src/features/repository/StashReapplyDialog.tsx— Cancel (and Esc, viaonOpenChange) stays enabled whilepending: it clears the request and closes the dialog but doesn't stop the in-flight compound, which then reports via toast. Disable it alongside the confirm button (disabled={pending}) and gateonOpenChangeon!pendingso the dialog can't be dismissed mid-stash.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
On the one leftover polish item (Cancel/Esc enabled while
pending): declining, with the reasoning on record. Close-immediately-report-by-toast is the deliberate house idiom for these flows — the switch dialog in this same PR closes before its compound even starts, and the recovery's completion toast reports into the closed-dialog state (exercised live in the round-0 dogfood). The compound is non-destructive at every exit (worst case: changes safely in the stash) and serialized on the repo lock, so a dismissal can't corrupt anything — while gatingonOpenChangeon!pendingwould hold the user inside a modal for up to a network-length pull with no way out and nothing to decide. If we'd rather lock it after all, the right execution is narrower than suggested: block dismissal only across the sub-second stash step (the only window where "mid-stash" is literally true), never across the network op — but as a default, dismissable-with-toast wins.With that recorded: every review item across all rounds is now fixed, declined-with-evidence, or deferred-with-a-home; the re-review verified the full round-2 batch against the code and raised nothing blocking; CI is green on this head across all eight checks. Merge-ready from this side.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Ticket changed by: theBGuy