diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 53b881b666..435d2115a1 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"; @@ -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, @@ -1564,6 +1573,226 @@ 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); + + 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("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(); + 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 remnants 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, "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); + }), + ); + + 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 e5547377b4..502f2e3fea 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, @@ -18,7 +19,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"; @@ -355,10 +356,33 @@ 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(" ")}`; } +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, @@ -2096,10 +2120,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", @@ -2116,12 +2159,127 @@ 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* () { + 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, + ["stash", "push", "-u", "-m", `t3code: stash before switching to ${input.branch}`], + { timeoutMs: 15_000, fallbackErrorMessage: "git stash failed" }, + ); + + 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", + input.cwd, + ["stash", "pop"], + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (popResult.code !== 0) { + yield* cleanupFailedStashPop(); + 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, @@ -2167,6 +2325,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 9f3bc0b9b9..b93b76faef 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -7,7 +7,7 @@ * @module GitCore */ import { Context } 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 c7b1fc4804..5ad5d7c2f3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -781,6 +781,28 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.gitStashAndCheckout]: (input) => + observeRpcEffect( + WS_METHODS.gitStashAndCheckout, + Effect.scoped(git.stashAndCheckout(input)).pipe( + 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" }, + ), + [WS_METHODS.gitStashDrop]: (input) => + 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 32c80f6542..8cee2b7f26 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,6 +1,6 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { EnvironmentApi, EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; +import { type QueryClient, useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -9,17 +9,20 @@ import { useDeferredValue, useEffect, useMemo, - useOptimistic, useRef, useState, - useTransition, } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { readEnvironmentApi } from "../environmentApi"; -import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; +import { + gitBranchSearchInfiniteQueryOptions, + gitQueryKeys, + invalidateGitQueries, +} from "../lib/gitReactQuery"; import { useGitStatus } from "../lib/gitStatusState"; import { newCommandId } from "../lib/utils"; +import { readLocalApi } from "../localApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; @@ -57,6 +60,139 @@ 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: EnvironmentApi; + environmentId: EnvironmentId; + cwd: string; + branch: string; + queryClient: QueryClient; + onSuccess: () => void; + fallbackTitle: string; + runBranchAction: (action: () => Promise) => void; + }, +): 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: () => { + 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(); + } 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 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", + 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: "local" | "worktree"; @@ -263,11 +399,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 @@ -282,12 +419,24 @@ export function BranchToolbarBranchSelector({ // Branch actions // --------------------------------------------------------------------------- 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) => { @@ -336,10 +485,18 @@ export function BranchToolbarBranchSelector({ setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), + handleCheckoutError(error, { + api, + environmentId, + cwd: selectionTarget.checkoutCwd, + branch: branch.name, + queryClient, + onSuccess: () => { + setOptimisticBranch(selectedBranchName); + setThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); + }, + fallbackTitle: "Failed to checkout branch.", + runBranchAction, }); } }); @@ -366,10 +523,18 @@ export function BranchToolbarBranchSelector({ setThreadBranch(createBranchResult.branch, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to create and checkout branch.", - description: toBranchActionErrorMessage(error), + handleCheckoutError(error, { + api, + environmentId, + cwd: branchCwd, + branch: name, + queryClient, + onSuccess: () => { + setOptimisticBranch(name); + setThreadBranch(name, activeWorktreePath); + }, + fallbackTitle: "Failed to create and checkout branch.", + runBranchAction, }); } }); diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index c0f75575ef..81d5d3c408 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -320,29 +320,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} - - )} ); @@ -414,31 +421,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/environmentApi.ts b/apps/web/src/environmentApi.ts index 5f23a53a75..9d0d1700d2 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -27,6 +27,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, diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b714889b7a..4356119f0a 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/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< @@ -184,6 +186,9 @@ export function createWsRpcClient(transport: 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 31e8693805..83982e9be3 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, @@ -233,6 +235,8 @@ export interface EnvironmentApi { 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 f47b427bcd..77cf399766 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -6,6 +6,7 @@ import { OpenError, OpenInEditorInput } from "./editor"; import { AuthAccessStreamEvent } from "./auth"; import { GitActionProgressEvent, + GitCheckoutDirtyWorktreeError, GitCheckoutInput, GitCheckoutResult, GitCommandError, @@ -25,6 +26,8 @@ import { GitRemoveWorktreeInput, GitResolvePullRequestResult, GitRunStackedActionInput, + GitStashAndCheckoutInput, + GitStashDropInput, GitStatusInput, GitStatusResult, GitStatusStreamEvent, @@ -94,6 +97,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", @@ -232,6 +237,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, }); @@ -362,6 +377,8 @@ export const WsRpcGroup = RpcGroup.make( WsGitRemoveWorktreeRpc, WsGitCreateBranchRpc, WsGitCheckoutRpc, + WsGitStashAndCheckoutRpc, + WsGitStashDropRpc, WsGitInitRpc, WsTerminalOpenRpc, WsTerminalWriteRpc,