From c3cac0a5db00c01918d7a482579131647ea5678e Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 6 Apr 2026 21:17:33 +0300 Subject: [PATCH 1/6] feat(git): friendly checkout error messages with stash & switch recovery Replace raw GitCommandError stack traces with structured, user-friendly error handling when branch checkout fails due to uncommitted changes. --- apps/server/src/git/Layers/GitCore.test.ts | 196 +++++++++++++++++- apps/server/src/git/Layers/GitCore.ts | 136 +++++++++++- apps/server/src/git/Services/GitCore.ts | 16 +- apps/server/src/ws.ts | 12 ++ .../BranchToolbarBranchSelector.tsx | 152 +++++++++++++- apps/web/src/components/ui/toast.tsx | 84 ++++---- apps/web/src/wsNativeApi.ts | 2 + apps/web/src/wsRpcClient.ts | 5 + packages/contracts/src/git.ts | 26 +++ packages/contracts/src/ipc.ts | 4 + packages/contracts/src/rpc.ts | 17 ++ 11 files changed, 592 insertions(+), 58 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5ff2714b61..b5876c3d1d 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -3,12 +3,12 @@ import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import { Cause, Effect, FileSystem, Layer, PlatformError, Schema, Scope } from "effect"; import { describe, expect, vi } from "vitest"; import { GitCoreLive, makeGitCore } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; -import { GitCommandError } from "@t3tools/contracts"; +import { GitCheckoutDirtyWorktreeError, GitCommandError } from "@t3tools/contracts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; import { ServerConfig } from "../../config.ts"; @@ -1532,6 +1532,198 @@ it.layer(TestLayer)("git integration", (it) => { ); }); + describe("stashAndCheckout", () => { + it.effect("stashes uncommitted changes, checks out, and pops stash", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "feature" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "feature" }); + yield* writeTextFile(path.join(tmp, "feature.txt"), "feature content\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "add feature file"]); + yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch }); + + yield* writeTextFile(path.join(tmp, "README.md"), "dirty changes\n"); + + yield* core.stashAndCheckout({ cwd: tmp, branch: "feature" }); + + const branches = yield* core.listBranches({ cwd: tmp }); + expect(branches.branches.find((b) => b.current)!.name).toBe("feature"); + + const stashList = yield* git(tmp, ["stash", "list"]); + expect(stashList.trim()).toBe(""); + }), + ); + + it.effect("includes descriptive stash message", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "target-branch" }); + + yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); + + const stashBefore = yield* git(tmp, ["stash", "list"]); + expect(stashBefore.trim()).toBe(""); + + yield* git(tmp, [ + "stash", + "push", + "-u", + "-m", + "t3code: stash before switching to target-branch", + ]); + const stashAfter = yield* git(tmp, ["stash", "list"]); + expect(stashAfter).toContain("t3code: stash before switching to target-branch"); + yield* git(tmp, ["stash", "pop"]); + }), + ); + + it.effect("cleans up and preserves stash on pop conflict", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "conflicting" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "conflicting" }); + yield* writeTextFile(path.join(tmp, "README.md"), "conflicting content\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "conflicting change"]); + yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch }); + + yield* writeTextFile(path.join(tmp, "README.md"), "local edits that will conflict\n"); + + const result = yield* Effect.result( + core.stashAndCheckout({ cwd: tmp, branch: "conflicting" }), + ); + expect(result._tag).toBe("Failure"); + + const stashList = yield* git(tmp, ["stash", "list"]); + expect(stashList).toContain("t3code:"); + }), + ); + + it.effect("cleans untracked files from failed stash pop", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "other" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "other" }); + yield* writeTextFile(path.join(tmp, "new-file.txt"), "new file on other\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "add new file on other"]); + yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch }); + + yield* writeTextFile(path.join(tmp, "new-file.txt"), "untracked content that conflicts\n"); + + const result = yield* Effect.result(core.stashAndCheckout({ cwd: tmp, branch: "other" })); + expect(result._tag).toBe("Failure"); + + const branches = yield* core.listBranches({ cwd: tmp }); + expect(branches.branches.find((b) => b.current)!.name).toBe("other"); + }), + ); + + it.effect("repo is usable after stash pop conflict", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "conflict-target" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "conflict-target" }); + yield* writeTextFile(path.join(tmp, "README.md"), "conflicting\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "diverge"]); + yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch }); + + yield* writeTextFile(path.join(tmp, "README.md"), "local dirty\n"); + + yield* Effect.result(core.stashAndCheckout({ cwd: tmp, branch: "conflict-target" })); + + const status = yield* core.status({ cwd: tmp }); + expect(status.isRepo).toBe(true); + expect(status.hasWorkingTreeChanges).toBe(false); + + yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch }); + const branchesAfter = yield* core.listBranches({ cwd: tmp }); + expect(branchesAfter.branches.find((b) => b.current)!.name).toBe(initialBranch); + }), + ); + }); + + describe("stashDrop", () => { + it.effect("drops the top stash entry", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "README.md"), "stashed changes\n"); + yield* git(tmp, ["stash", "push", "-m", "test stash"]); + + const stashBefore = yield* git(tmp, ["stash", "list"]); + expect(stashBefore).toContain("test stash"); + + yield* core.stashDrop(tmp); + + const stashAfter = yield* git(tmp, ["stash", "list"]); + expect(stashAfter.trim()).toBe(""); + }), + ); + + it.effect("fails when stash is empty", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + const result = yield* Effect.result(core.stashDrop(tmp)); + expect(result._tag).toBe("Failure"); + }), + ); + }); + + describe("checkoutBranch untracked conflicts", () => { + it.effect("raises GitCheckoutDirtyWorktreeError for untracked file conflicts", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "with-tracked-file" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "with-tracked-file" }); + yield* writeTextFile(path.join(tmp, "conflict.txt"), "tracked content\n"); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", "add tracked file"]); + yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch }); + + yield* writeTextFile(path.join(tmp, "conflict.txt"), "untracked content\n"); + + const result = yield* Effect.exit( + core.checkoutBranch({ cwd: tmp, branch: "with-tracked-file" }), + ); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + expect(Schema.is(GitCheckoutDirtyWorktreeError)(error)).toBe(true); + if (Schema.is(GitCheckoutDirtyWorktreeError)(error)) { + expect(error.conflictingFiles).toContain("conflict.txt"); + expect(error.branch).toBe("with-tracked-file"); + } + } + }), + ); + }); + describe("GitCore", () => { it.effect("supports branch lifecycle operations through the service API", () => Effect.gen(function* () { diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 911a601955..6cf9339116 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -18,7 +18,7 @@ import { } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type GitBranch } from "@t3tools/contracts"; +import { GitCheckoutDirtyWorktreeError, GitCommandError, type GitBranch } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "../../observability/Attributes.ts"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts"; @@ -349,6 +349,21 @@ function createGitCommandError( }); } +const DIRTY_WORKTREE_PATTERN = + /Your local changes to the following files would be overwritten by (?:checkout|merge):\s*([\s\S]*?)Please commit your changes or stash them/; + +const UNTRACKED_OVERWRITE_PATTERN = + /The following untracked working tree files would be overwritten by checkout:\s*([\s\S]*?)Please move or remove them/; + +function parseDirtyWorktreeFiles(stderr: string): string[] | null { + const match = DIRTY_WORKTREE_PATTERN.exec(stderr) ?? UNTRACKED_OVERWRITE_PATTERN.exec(stderr); + if (!match?.[1]) return null; + return match[1] + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + function quoteGitCommand(args: ReadonlyArray): string { return `git ${args.join(" ")}`; } @@ -2077,10 +2092,29 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ? ["checkout", localTrackingBranch] : ["checkout", input.branch]; - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); + const checkoutResult = yield* executeGit( + "GitCore.checkoutBranch.checkout", + input.cwd, + checkoutArgs, + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (checkoutResult.code !== 0) { + const dirtyFiles = parseDirtyWorktreeFiles(checkoutResult.stderr); + if (dirtyFiles && dirtyFiles.length > 0) { + return yield* new GitCheckoutDirtyWorktreeError({ + branch: input.branch, + cwd: input.cwd, + conflictingFiles: dirtyFiles, + }); + } + const stderr = checkoutResult.stderr.trim(); + return yield* createGitCommandError( + "GitCore.checkoutBranch.checkout", + input.cwd, + checkoutArgs, + stderr.length > 0 ? stderr : "git checkout failed", + ); + } const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ "branch", @@ -2097,12 +2131,100 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { fallbackErrorMessage: "git branch create failed", }); if (input.checkout) { - yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); + yield* Effect.scoped( + checkoutBranch({ cwd: input.cwd, branch: input.branch }).pipe( + Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) => + Effect.fail( + createGitCommandError( + "GitCore.createBranch.checkout", + input.cwd, + ["checkout", input.branch], + e.message, + ), + ), + ), + ), + ); } return { branch: input.branch }; }); + const stashAndCheckout: GitCoreShape["stashAndCheckout"] = (input) => + Effect.gen(function* () { + yield* executeGit( + "GitCore.stashAndCheckout.stash", + input.cwd, + ["stash", "push", "-u", "-m", `t3code: stash before switching to ${input.branch}`], + { timeoutMs: 15_000, fallbackErrorMessage: "git stash failed" }, + ); + + yield* checkoutBranch(input).pipe( + Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) => + Effect.fail( + createGitCommandError( + "GitCore.stashAndCheckout.checkout", + input.cwd, + ["checkout", input.branch], + e.message, + ), + ), + ), + ); + + const popResult = yield* executeGit( + "GitCore.stashAndCheckout.stashPop", + input.cwd, + ["stash", "pop"], + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (popResult.code !== 0) { + const stashFiles = yield* executeGit( + "GitCore.stashAndCheckout.stashFileList", + input.cwd, + ["stash", "show", "--name-only"], + { timeoutMs: 5_000, allowNonZeroExit: true }, + ); + yield* executeGit("GitCore.stashAndCheckout.resetIndex", input.cwd, ["reset", "HEAD"], { + timeoutMs: 10_000, + allowNonZeroExit: true, + }); + yield* executeGit( + "GitCore.stashAndCheckout.restoreWorktree", + input.cwd, + ["checkout", "--", "."], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (stashFiles.code === 0 && stashFiles.stdout.trim().length > 0) { + const filePaths = stashFiles.stdout + .trim() + .split("\n") + .map((f) => f.trim()) + .filter((f) => f.length > 0); + if (filePaths.length > 0) { + yield* executeGit( + "GitCore.stashAndCheckout.cleanStashRemnants", + input.cwd, + ["clean", "-f", "--", ...filePaths], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + } + } + return yield* createGitCommandError( + "GitCore.stashAndCheckout.stashPop", + input.cwd, + ["stash", "pop"], + "Stash could not be applied to this branch. Your changes are saved in the stash.", + ); + } + }); + + const stashDrop: GitCoreShape["stashDrop"] = (cwd) => + executeGit("GitCore.stashDrop", cwd, ["stash", "drop"], { + timeoutMs: 10_000, + fallbackErrorMessage: "git stash drop failed", + }).pipe(Effect.asVoid); + const initRepo: GitCoreShape["initRepo"] = (input) => executeGit("GitCore.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, @@ -2148,6 +2270,8 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { renameBranch, createBranch, checkoutBranch, + stashAndCheckout, + stashDrop, initRepo, listLocalBranchNames, } satisfies GitCoreShape; diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 015efa8bbd..39d72b945f 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -7,7 +7,7 @@ * @module GitCore */ import { ServiceMap } from "effect"; -import type { Effect } from "effect"; +import type { Effect, Scope } from "effect"; import type { GitCheckoutInput, GitCheckoutResult, @@ -24,7 +24,7 @@ import type { GitStatusResult, } from "@t3tools/contracts"; -import type { GitCommandError } from "@t3tools/contracts"; +import type { GitCheckoutDirtyWorktreeError, GitCommandError } from "@t3tools/contracts"; export interface ExecuteGitInput { readonly operation: string; @@ -294,7 +294,17 @@ export interface GitCoreShape { */ readonly checkoutBranch: ( input: GitCheckoutInput, - ) => Effect.Effect; + ) => Effect.Effect< + GitCheckoutResult, + GitCommandError | GitCheckoutDirtyWorktreeError, + Scope.Scope + >; + + readonly stashAndCheckout: ( + input: GitCheckoutInput, + ) => Effect.Effect; + + readonly stashDrop: (cwd: string) => Effect.Effect; /** * Initialize a repository in the provided directory. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca096bff33..25054bded0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -654,6 +654,18 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.gitStashAndCheckout]: (input) => + observeRpcEffect( + WS_METHODS.gitStashAndCheckout, + Effect.scoped(git.stashAndCheckout(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitStashDrop]: (input) => + observeRpcEffect(WS_METHODS.gitStashDrop, git.stashDrop(input.cwd), { + "rpc.aggregate": "git", + }), [WS_METHODS.gitInit]: (input) => observeRpcEffect( WS_METHODS.gitInit, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index a1623a41db..ccd772df0b 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ -import type { GitBranch } from "@t3tools/contracts"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { GitBranch, NativeApi } from "@t3tools/contracts"; +import { type QueryClient, useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -14,7 +14,11 @@ import { useTransition, } from "react"; -import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; +import { + gitBranchSearchInfiniteQueryOptions, + gitQueryKeys, + invalidateGitQueries, +} from "../lib/gitReactQuery"; import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; @@ -54,6 +58,120 @@ function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +const DIRTY_WORKTREE_ERROR_PATTERN = /Uncommitted changes block checkout to ([^:]+): (.+)/; + +function parseDirtyWorktreeError(error: unknown): { branch: string; files: string[] } | null { + const message = error instanceof Error ? error.message : String(error); + const match = DIRTY_WORKTREE_ERROR_PATTERN.exec(message); + if (!match?.[1] || !match[2]) return null; + return { + branch: match[1], + files: match[2].split(", ").map((f) => f.trim()), + }; +} + +const STASH_CONFLICT_PATTERN = /Stash could not be applied|Stash applied with merge conflicts/; + +function isStashConflictError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return STASH_CONFLICT_PATTERN.test(message); +} + +const UNRESOLVED_INDEX_PATTERN = /you need to resolve your current index/; + +function isUnresolvedIndexError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return UNRESOLVED_INDEX_PATTERN.test(message); +} + +function formatDirtyWorktreeDescription(files: string[]): string { + const basenames = files.map((f) => f.split("/").pop() ?? f); + if (basenames.length <= 3) { + return `${basenames.join(", ")} ${basenames.length === 1 ? "has" : "have"} uncommitted changes. Commit or stash before switching.`; + } + return `${basenames.slice(0, 2).join(", ")} and ${basenames.length - 2} other file${basenames.length - 2 === 1 ? "" : "s"} have uncommitted changes. Commit or stash before switching.`; +} + +function handleCheckoutError( + error: unknown, + ctx: { + api: NativeApi; + cwd: string; + branch: string; + queryClient: QueryClient; + onSuccess: () => void; + fallbackTitle: string; + }, +): void { + const dirtyWorktree = parseDirtyWorktreeError(error); + if (dirtyWorktree) { + toastManager.add({ + type: "warning", + title: "Uncommitted changes block checkout.", + description: formatDirtyWorktreeDescription(dirtyWorktree.files), + actionProps: { + children: "Stash & Switch", + onClick: async () => { + try { + await ctx.api.git.stashAndCheckout({ cwd: ctx.cwd, branch: ctx.branch }); + await invalidateGitQueries(ctx.queryClient); + ctx.onSuccess(); + } catch (stashError) { + if (isStashConflictError(stashError)) { + await invalidateGitQueries(ctx.queryClient); + ctx.onSuccess(); + toastManager.add({ + type: "warning", + title: "Stash could not be applied.", + description: + "Your stashed changes could not be applied to this branch. They are saved in the stash.", + actionProps: { + children: "Discard stash", + onClick: async () => { + const confirmed = await readNativeApi()?.dialogs.confirm( + "Drop the most recent stash entry? This cannot be undone.", + ); + if (!confirmed) return; + try { + await ctx.api.git.stashDrop({ cwd: ctx.cwd }); + } catch (dropError) { + toastManager.add({ + type: "error", + title: "Failed to drop stash.", + description: toBranchActionErrorMessage(dropError), + }); + } + }, + }, + }); + } else { + toastManager.add({ + type: "error", + title: "Failed to stash and switch.", + description: toBranchActionErrorMessage(stashError), + }); + } + } + }, + }, + }); + return; + } + if (isUnresolvedIndexError(error)) { + toastManager.add({ + type: "error", + title: "Unresolved conflicts in the repository.", + description: toBranchActionErrorMessage(error), + }); + return; + } + toastManager.add({ + type: "error", + title: ctx.fallbackTitle, + description: toBranchActionErrorMessage(error), + }); +} + function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: EnvMode; @@ -237,10 +355,16 @@ export function BranchToolbarBranchSelector({ onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), + handleCheckoutError(error, { + api, + cwd: selectionTarget.checkoutCwd, + branch: branch.name, + queryClient, + onSuccess: () => { + setOptimisticBranch(selectedBranchName); + onSetThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); + }, + fallbackTitle: "Failed to checkout branch.", }); } }); @@ -267,10 +391,16 @@ export function BranchToolbarBranchSelector({ onSetThreadBranch(createBranchResult.branch, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to create and checkout branch.", - description: toBranchActionErrorMessage(error), + handleCheckoutError(error, { + api, + cwd: branchCwd, + branch: name, + queryClient, + onSuccess: () => { + setOptimisticBranch(name); + onSetThreadBranch(name, activeWorktreePath); + }, + fallbackTitle: "Failed to create and checkout branch.", }); } }); diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index dbe546cc82..54e4316e1b 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -307,29 +307,36 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { )}
-
- - {toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton && } -
+ + {(toast.actionProps || + ((toast.type === "error" || toast.type === "warning") && + typeof toast.description === "string" && + !toast.data?.hideCopyButton)) && ( +
+ {(toast.type === "error" || toast.type === "warning") && + typeof toast.description === "string" && + !toast.data?.hideCopyButton && ( + + )} + {toast.actionProps && ( + + {toast.actionProps.children} + + )} +
+ )}
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} ); @@ -401,31 +408,36 @@ function AnchoredToasts() { )}
-
- - {toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton && ( - - )} -
+ + {(toast.actionProps || + ((toast.type === "error" || toast.type === "warning") && + typeof toast.description === "string" && + !toast.data?.hideCopyButton)) && ( +
+ {(toast.type === "error" || toast.type === "warning") && + typeof toast.description === "string" && + !toast.data?.hideCopyButton && ( + + )} + {toast.actionProps && ( + + {toast.actionProps.children} + + )} +
+ )}
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} )} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 3cfb976e09..8fc7d71ba8 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -76,6 +76,8 @@ export function createWsNativeApi(): NativeApi { removeWorktree: rpcClient.git.removeWorktree, createBranch: rpcClient.git.createBranch, checkout: rpcClient.git.checkout, + stashAndCheckout: rpcClient.git.stashAndCheckout, + stashDrop: rpcClient.git.stashDrop, init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 997b83d2d7..1a5b4fb4bd 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -82,6 +82,8 @@ export interface WsRpcClient { readonly removeWorktree: RpcUnaryMethod; readonly createBranch: RpcUnaryMethod; readonly checkout: RpcUnaryMethod; + readonly stashAndCheckout: RpcUnaryMethod; + readonly stashDrop: RpcUnaryMethod; readonly init: RpcUnaryMethod; readonly resolvePullRequest: RpcUnaryMethod; readonly preparePullRequestThread: RpcUnaryMethod< @@ -198,6 +200,9 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { createBranch: (input) => transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), + stashAndCheckout: (input) => + transport.request((client) => client[WS_METHODS.gitStashAndCheckout](input)), + stashDrop: (input) => transport.request((client) => client[WS_METHODS.gitStashDrop](input)), init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), resolvePullRequest: (input) => transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 345208acf9..85739d7e4b 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -184,6 +184,17 @@ export const GitCheckoutInput = Schema.Struct({ }); export type GitCheckoutInput = typeof GitCheckoutInput.Type; +export const GitStashAndCheckoutInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema, +}); +export type GitStashAndCheckoutInput = typeof GitStashAndCheckoutInput.Type; + +export const GitStashDropInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type GitStashDropInput = typeof GitStashDropInput.Type; + export const GitInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); @@ -366,11 +377,26 @@ export class GitManagerError extends Schema.TaggedErrorClass()( } } +export class GitCheckoutDirtyWorktreeError extends Schema.TaggedErrorClass()( + "GitCheckoutDirtyWorktreeError", + { + branch: Schema.String, + cwd: Schema.String, + conflictingFiles: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const fileList = this.conflictingFiles.join(", "); + return `Uncommitted changes block checkout to ${this.branch}: ${fileList}`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, GitCommandError, GitHubCliError, TextGenerationError, + GitCheckoutDirtyWorktreeError, ]); export type GitManagerServiceError = typeof GitManagerServiceError.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd8249..c69be78163 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -2,6 +2,8 @@ import type { GitCheckoutInput, GitCheckoutResult, GitCreateBranchInput, + GitStashAndCheckoutInput, + GitStashDropInput, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, @@ -152,6 +154,8 @@ export interface NativeApi { removeWorktree: (input: GitRemoveWorktreeInput) => Promise; createBranch: (input: GitCreateBranchInput) => Promise; checkout: (input: GitCheckoutInput) => Promise; + stashAndCheckout: (input: GitStashAndCheckoutInput) => Promise; + stashDrop: (input: GitStashDropInput) => Promise; init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; preparePullRequestThread: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a3d10299df..715dc53655 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -5,6 +5,7 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor"; import { GitActionProgressEvent, + GitCheckoutDirtyWorktreeError, GitCheckoutInput, GitCheckoutResult, GitCommandError, @@ -24,6 +25,8 @@ import { GitRemoveWorktreeInput, GitResolvePullRequestResult, GitRunStackedActionInput, + GitStashAndCheckoutInput, + GitStashDropInput, GitStatusInput, GitStatusResult, GitStatusStreamEvent, @@ -93,6 +96,8 @@ export const WS_METHODS = { gitRemoveWorktree: "git.removeWorktree", gitCreateBranch: "git.createBranch", gitCheckout: "git.checkout", + gitStashAndCheckout: "git.stashAndCheckout", + gitStashDrop: "git.stashDrop", gitInit: "git.init", gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", @@ -230,6 +235,16 @@ export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { payload: GitCheckoutInput, success: GitCheckoutResult, + error: Schema.Union([GitCommandError, GitCheckoutDirtyWorktreeError]), +}); + +export const WsGitStashAndCheckoutRpc = Rpc.make(WS_METHODS.gitStashAndCheckout, { + payload: GitStashAndCheckoutInput, + error: GitCommandError, +}); + +export const WsGitStashDropRpc = Rpc.make(WS_METHODS.gitStashDrop, { + payload: GitStashDropInput, error: GitCommandError, }); @@ -354,6 +369,8 @@ export const WsRpcGroup = RpcGroup.make( WsGitRemoveWorktreeRpc, WsGitCreateBranchRpc, WsGitCheckoutRpc, + WsGitStashAndCheckoutRpc, + WsGitStashDropRpc, WsGitInitRpc, WsTerminalOpenRpc, WsTerminalWriteRpc, From e3db0ea83f6bbb0ffd004ab6a2441cb672256aea Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 9 Apr 2026 18:08:15 +0300 Subject: [PATCH 2/6] feat(git): wire stash recovery into EnvironmentApi and fix optimistic UI - Add stashAndCheckout/stashDrop to EnvironmentApi bindings and RPC wiring - Replace useOptimistic with useState to work without useTransition - Wrap toast actions in runBranchAction for concurrency safety - Pass environmentId through checkout error context for query invalidation - Add missing Cause import for GitCore stash error handling - Use --include-untracked in stash cleanup to capture all remnants --- apps/server/src/git/Layers/GitCore.test.ts | 45 +++++- apps/server/src/git/Layers/GitCore.ts | 114 ++++++++++------ apps/server/src/ws.ts | 10 +- .../BranchToolbarBranchSelector.tsx | 129 +++++++++++------- apps/web/src/environmentApi.ts | 2 + 5 files changed, 205 insertions(+), 95 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index b5876c3d1d..813cfe9f2a 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -40,6 +40,15 @@ function writeTextFile( }); } +function readTextFile( + filePath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.readFileString(filePath); + }); +} + /** Run a raw git command for test setup (not under test). */ function git( cwd: string, @@ -1562,9 +1571,6 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "target-branch" }); yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); @@ -1584,6 +1590,27 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("restores local changes when checkout fails after stashing", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "README.md"), "dirty changes\n"); + + const result = yield* Effect.result( + core.stashAndCheckout({ cwd: tmp, branch: "missing-branch" }), + ); + expect(result._tag).toBe("Failure"); + + const readme = yield* readTextFile(path.join(tmp, "README.md")); + expect(readme).toContain("dirty changes"); + + const stashList = yield* git(tmp, ["stash", "list"]); + expect(stashList.trim()).toBe(""); + }), + ); + it.effect("cleans up and preserves stash on pop conflict", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -1609,7 +1636,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("cleans untracked files from failed stash pop", () => + it.effect("cleans untracked remnants from failed stash pop", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(tmp); @@ -1622,13 +1649,23 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["commit", "-m", "add new file on other"]); yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch }); + yield* writeTextFile(path.join(tmp, "README.md"), "local edits that will conflict\n"); yield* writeTextFile(path.join(tmp, "new-file.txt"), "untracked content that conflicts\n"); + yield* writeTextFile(path.join(tmp, "leftover.txt"), "temporary untracked file\n"); const result = yield* Effect.result(core.stashAndCheckout({ cwd: tmp, branch: "other" })); expect(result._tag).toBe("Failure"); const branches = yield* core.listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("other"); + + const status = yield* core.status({ cwd: tmp }); + expect(status.hasWorkingTreeChanges).toBe(false); + + const newFile = yield* readTextFile(path.join(tmp, "new-file.txt")); + expect(newFile).toBe("new file on other\n"); + + expect(existsSync(path.join(tmp, "leftover.txt"))).toBe(false); }), ); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 6cf9339116..9d71f0da4a 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1,5 +1,6 @@ import { Cache, + Cause, Data, Duration, Effect, @@ -368,6 +369,14 @@ function quoteGitCommand(args: ReadonlyArray): string { return `git ${args.join(" ")}`; } +function parseNonEmptyLineList(input: string): string[] { + return input + .trim() + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + function toGitCommandError( input: Pick, detail: string, @@ -2152,6 +2161,41 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const stashAndCheckout: GitCoreShape["stashAndCheckout"] = (input) => Effect.gen(function* () { + const cleanupFailedStashPop = () => + Effect.gen(function* () { + const stashFiles = yield* executeGit( + "GitCore.stashAndCheckout.stashFileList", + input.cwd, + ["stash", "show", "--include-untracked", "--name-only"], + { timeoutMs: 5_000, allowNonZeroExit: true }, + ); + yield* executeGit("GitCore.stashAndCheckout.resetIndex", input.cwd, ["reset", "HEAD"], { + timeoutMs: 10_000, + allowNonZeroExit: true, + }); + yield* executeGit( + "GitCore.stashAndCheckout.restoreWorktree", + input.cwd, + ["checkout", "--", "."], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (stashFiles.code !== 0 || stashFiles.stdout.trim().length === 0) { + return; + } + + const filePaths = parseNonEmptyLineList(stashFiles.stdout); + if (filePaths.length === 0) { + return; + } + + yield* executeGit( + "GitCore.stashAndCheckout.cleanStashRemnants", + input.cwd, + ["clean", "-f", "--", ...filePaths], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + }); + yield* executeGit( "GitCore.stashAndCheckout.stash", input.cwd, @@ -2159,18 +2203,40 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { { timeoutMs: 15_000, fallbackErrorMessage: "git stash failed" }, ); - yield* checkoutBranch(input).pipe( - Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) => - Effect.fail( - createGitCommandError( - "GitCore.stashAndCheckout.checkout", - input.cwd, - ["checkout", input.branch], - e.message, + const checkoutExit = yield* Effect.exit( + checkoutBranch(input).pipe( + Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) => + Effect.fail( + createGitCommandError( + "GitCore.stashAndCheckout.checkout", + input.cwd, + ["checkout", input.branch], + e.message, + ), ), ), ), ); + if (Exit.isFailure(checkoutExit)) { + const restoreResult = yield* executeGit( + "GitCore.stashAndCheckout.restoreOriginalBranch", + input.cwd, + ["stash", "pop"], + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (restoreResult.code !== 0) { + yield* cleanupFailedStashPop(); + return yield* createGitCommandError( + "GitCore.stashAndCheckout.checkout", + input.cwd, + ["checkout", input.branch], + "Branch switch failed after stashing. Your changes are saved in the stash.", + Cause.squash(checkoutExit.cause), + ); + } + + return yield* Effect.failCause(checkoutExit.cause); + } const popResult = yield* executeGit( "GitCore.stashAndCheckout.stashPop", @@ -2179,37 +2245,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { { timeoutMs: 15_000, allowNonZeroExit: true }, ); if (popResult.code !== 0) { - const stashFiles = yield* executeGit( - "GitCore.stashAndCheckout.stashFileList", - input.cwd, - ["stash", "show", "--name-only"], - { timeoutMs: 5_000, allowNonZeroExit: true }, - ); - yield* executeGit("GitCore.stashAndCheckout.resetIndex", input.cwd, ["reset", "HEAD"], { - timeoutMs: 10_000, - allowNonZeroExit: true, - }); - yield* executeGit( - "GitCore.stashAndCheckout.restoreWorktree", - input.cwd, - ["checkout", "--", "."], - { timeoutMs: 10_000, allowNonZeroExit: true }, - ); - if (stashFiles.code === 0 && stashFiles.stdout.trim().length > 0) { - const filePaths = stashFiles.stdout - .trim() - .split("\n") - .map((f) => f.trim()) - .filter((f) => f.length > 0); - if (filePaths.length > 0) { - yield* executeGit( - "GitCore.stashAndCheckout.cleanStashRemnants", - input.cwd, - ["clean", "-f", "--", ...filePaths], - { timeoutMs: 10_000, allowNonZeroExit: true }, - ); - } - } + yield* cleanupFailedStashPop(); return yield* createGitCommandError( "GitCore.stashAndCheckout.stashPop", input.cwd, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2809679adc..496f179d1d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -712,7 +712,15 @@ const WsRpcLayer = WsRpcGroup.toLayer( observeRpcEffect( WS_METHODS.gitStashAndCheckout, Effect.scoped(git.stashAndCheckout(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), + Effect.matchCauseEffect({ + onFailure: (cause) => + refreshGitStatus(input.cwd).pipe( + Effect.ignore({ log: true }), + Effect.andThen(Effect.failCause(cause)), + ), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), ), { "rpc.aggregate": "git" }, ), diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c0847b847e..9d353f19cb 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -8,10 +8,8 @@ import { useDeferredValue, useEffect, useMemo, - useOptimistic, useRef, useState, - useTransition, } from "react"; import { @@ -20,8 +18,8 @@ import { invalidateGitQueries, } from "../lib/gitReactQuery"; import { useGitStatus } from "../lib/gitStatusState"; -import { readEnvironmentApi } from "../environmentApi"; import { readLocalApi } from "../localApi"; +import { readEnvironmentApi } from "../environmentApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { deriveLocalBranchNameFromRemoteRef, @@ -98,11 +96,13 @@ function handleCheckoutError( error: unknown, ctx: { api: EnvironmentApi; + environmentId: EnvironmentId; cwd: string; branch: string; queryClient: QueryClient; onSuccess: () => void; fallbackTitle: string; + runBranchAction: (action: () => Promise) => void; }, ): void { const dirtyWorktree = parseDirtyWorktreeError(error); @@ -113,47 +113,57 @@ function handleCheckoutError( description: formatDirtyWorktreeDescription(dirtyWorktree.files), actionProps: { children: "Stash & Switch", - onClick: async () => { - try { - await ctx.api.git.stashAndCheckout({ cwd: ctx.cwd, branch: ctx.branch }); - await invalidateGitQueries(ctx.queryClient); - ctx.onSuccess(); - } catch (stashError) { - if (isStashConflictError(stashError)) { - await invalidateGitQueries(ctx.queryClient); + onClick: () => { + ctx.runBranchAction(async () => { + try { + await ctx.api.git.stashAndCheckout({ cwd: ctx.cwd, branch: ctx.branch }); + await invalidateGitQueries(ctx.queryClient, { + environmentId: ctx.environmentId, + cwd: ctx.cwd, + }); ctx.onSuccess(); - toastManager.add({ - type: "warning", - title: "Stash could not be applied.", - description: - "Your stashed changes could not be applied to this branch. They are saved in the stash.", - actionProps: { - children: "Discard stash", - onClick: async () => { - const confirmed = await readLocalApi()?.dialogs.confirm( - "Drop the most recent stash entry? This cannot be undone.", - ); - if (!confirmed) return; - try { - await ctx.api.git.stashDrop({ cwd: ctx.cwd }); - } catch (dropError) { - toastManager.add({ - type: "error", - title: "Failed to drop stash.", - description: toBranchActionErrorMessage(dropError), + } catch (stashError) { + if (isStashConflictError(stashError)) { + await invalidateGitQueries(ctx.queryClient, { + environmentId: ctx.environmentId, + cwd: ctx.cwd, + }); + ctx.onSuccess(); + toastManager.add({ + type: "warning", + title: "Stash could not be applied.", + description: + "Your stashed changes could not be applied to this branch. They are saved in the stash.", + actionProps: { + children: "Discard stash", + onClick: () => { + ctx.runBranchAction(async () => { + const confirmed = await readLocalApi()?.dialogs.confirm( + "Drop the most recent stash entry? This cannot be undone.", + ); + if (!confirmed) return; + try { + await ctx.api.git.stashDrop({ cwd: ctx.cwd }); + } catch (dropError) { + toastManager.add({ + type: "error", + title: "Failed to drop stash.", + description: toBranchActionErrorMessage(dropError), + }); + } }); - } + }, }, - }, - }); - } else { - toastManager.add({ - type: "error", - title: "Failed to stash and switch.", - description: toBranchActionErrorMessage(stashError), - }); + }); + } else { + toastManager.add({ + type: "error", + title: "Failed to stash and switch.", + description: toBranchActionErrorMessage(stashError), + }); + } } - } + }); }, }, }); @@ -287,11 +297,12 @@ export function BranchToolbarBranchSelector({ normalizedDeferredBranchQuery, ], ); - const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( - canonicalActiveBranch, - (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, - ); - const [isBranchActionPending, startBranchActionTransition] = useTransition(); + const [resolvedActiveBranch, setOptimisticBranch] = useState(canonicalActiveBranch); + useEffect(() => { + setOptimisticBranch(canonicalActiveBranch); + }, [canonicalActiveBranch]); + const [isBranchActionPending, setIsBranchActionPending] = useState(false); + const isBranchActionPendingRef = useRef(false); const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending @@ -303,12 +314,24 @@ export function BranchToolbarBranchSelector({ : null; const runBranchAction = (action: () => Promise) => { - startBranchActionTransition(async () => { - await action().catch(() => undefined); - await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) - .catch(() => undefined); - }); + if (isBranchActionPendingRef.current) { + return; + } + + isBranchActionPendingRef.current = true; + setIsBranchActionPending(true); + + void (async () => { + try { + await action().catch(() => undefined); + await queryClient + .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) + .catch(() => undefined); + } finally { + isBranchActionPendingRef.current = false; + setIsBranchActionPending(false); + } + })(); }; const selectBranch = (branch: GitBranch) => { @@ -361,6 +384,7 @@ export function BranchToolbarBranchSelector({ setOptimisticBranch(previousBranch); handleCheckoutError(error, { api, + environmentId, cwd: selectionTarget.checkoutCwd, branch: branch.name, queryClient, @@ -369,6 +393,7 @@ export function BranchToolbarBranchSelector({ onSetThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); }, fallbackTitle: "Failed to checkout branch.", + runBranchAction, }); } }); @@ -397,6 +422,7 @@ export function BranchToolbarBranchSelector({ setOptimisticBranch(previousBranch); handleCheckoutError(error, { api, + environmentId, cwd: branchCwd, branch: name, queryClient, @@ -405,6 +431,7 @@ export function BranchToolbarBranchSelector({ onSetThreadBranch(name, activeWorktreePath); }, fallbackTitle: "Failed to create and checkout branch.", + runBranchAction, }); } }); diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index a565940687..8209b0c910 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -26,6 +26,8 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { removeWorktree: rpcClient.git.removeWorktree, createBranch: rpcClient.git.createBranch, checkout: rpcClient.git.checkout, + stashAndCheckout: rpcClient.git.stashAndCheckout, + stashDrop: rpcClient.git.stashDrop, init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, From a5bd0f25666cfd22e2f20626eab380dae08bdeba Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 10 Apr 2026 19:19:02 +0300 Subject: [PATCH 3/6] Fix client settings type coverage for thread preview count --- apps/desktop/src/clientPersistence.test.ts | 1 + apps/web/src/localApi.test.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index df2178c0b0..272d5a43f7 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 68047f4495..f4260920cb 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -503,6 +503,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); const setClientSettings = vi.fn().mockResolvedValue(undefined); @@ -531,6 +532,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.getSavedEnvironmentRegistry(); @@ -549,6 +551,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); @@ -568,6 +571,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.setSavedEnvironmentRegistry([ @@ -591,6 +595,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ From 2fedadc50f2d4c141b4cbb8264e1dfd2a29cc126 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 10 Apr 2026 19:23:22 +0300 Subject: [PATCH 4/6] Match client settings fixture to desktop contract --- apps/desktop/src/clientPersistence.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 272d5a43f7..df2178c0b0 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -54,7 +54,6 @@ const clientSettings: ClientSettings = { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }; From c8b0687a68f0261864cf51e0081ab526b45d6a68 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 10 Apr 2026 19:24:54 +0300 Subject: [PATCH 5/6] Back out sidebar preview setting fixture usage in 1785 web tests --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 4 ++-- apps/web/src/localApi.test.ts | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ad360cb06e..a6d126b31a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -486,7 +486,7 @@ export function BranchToolbarBranchSelector({ queryClient, onSuccess: () => { setOptimisticBranch(selectedBranchName); - onSetThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); + setThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); }, fallbackTitle: "Failed to checkout branch.", runBranchAction, @@ -524,7 +524,7 @@ export function BranchToolbarBranchSelector({ queryClient, onSuccess: () => { setOptimisticBranch(name); - onSetThreadBranch(name, activeWorktreePath); + setThreadBranch(name, activeWorktreePath); }, fallbackTitle: "Failed to create and checkout branch.", runBranchAction, diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index f4260920cb..68047f4495 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -503,7 +503,6 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); const setClientSettings = vi.fn().mockResolvedValue(undefined); @@ -532,7 +531,6 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.getSavedEnvironmentRegistry(); @@ -551,7 +549,6 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); @@ -571,7 +568,6 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.setSavedEnvironmentRegistry([ @@ -595,7 +591,6 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ From 8d9f042e2c1e15a5a2c5fa64682cc2b8c739b11c Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 10 Apr 2026 20:52:43 +0300 Subject: [PATCH 6/6] fix(git): add missing refreshGitStatus to stashDrop and handle persistent dirty worktree errors - Add refreshGitStatus(input.cwd) to the gitStashDrop WS handler so clients see updated stash state after a drop, matching all other git mutation handlers. - Detect when stashAndCheckout fails because ignored files still conflict (stash -u doesn't capture .gitignore'd files) and show an actionable error instead of a confusing dirty-worktree message. --- apps/server/src/ws.ts | 8 +++++--- apps/web/src/components/BranchToolbarBranchSelector.tsx | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a625ef8038..5ad5d7c2f3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -798,9 +798,11 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => { "rpc.aggregate": "git" }, ), [WS_METHODS.gitStashDrop]: (input) => - observeRpcEffect(WS_METHODS.gitStashDrop, git.stashDrop(input.cwd), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitStashDrop, + git.stashDrop(input.cwd).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitInit]: (input) => observeRpcEffect( WS_METHODS.gitInit, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index a6d126b31a..8cee2b7f26 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -157,6 +157,13 @@ function handleCheckoutError( }, }, }); + } else if (parseDirtyWorktreeError(stashError)) { + toastManager.add({ + type: "error", + title: "Cannot switch branches.", + description: + "Some conflicting files are not covered by git stash (e.g., files in .gitignore). Remove or move them manually before switching.", + }); } else { toastManager.add({ type: "error",