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,