diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 74064450fc..8dc45db5ab 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -19,6 +19,10 @@ import { type GitPullRequestRefInput, type VcsPullResult, type VcsRemoveWorktreeInput, + type VcsStashAndSwitchInput, + type VcsStashDropInput, + type VcsStashInfoInput, + type VcsStashInfoResult, type GitResolvePullRequestResult, type GitRunStackedActionInput, type GitRunStackedActionResult, @@ -67,6 +71,11 @@ export interface GitWorkflowServiceShape { readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; + readonly stashAndSwitch: (input: VcsStashAndSwitchInput) => Effect.Effect; + readonly stashDrop: (input: VcsStashDropInput) => Effect.Effect; + readonly stashInfo: ( + input: VcsStashInfoInput, + ) => Effect.Effect; readonly renameBranch: (input: { readonly cwd: string; readonly oldBranch: string; @@ -306,6 +315,18 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( Effect.andThen(Effect.scoped(git.switchRef(input))), ), + stashAndSwitch: (input) => + ensureGitCommand("GitWorkflowService.stashAndSwitch", input.cwd).pipe( + Effect.andThen(Effect.scoped(git.stashAndSwitch(input))), + ), + stashDrop: (input) => + ensureGitCommand("GitWorkflowService.stashDrop", input.cwd).pipe( + Effect.andThen(git.stashDrop(input)), + ), + stashInfo: (input) => + ensureGitCommand("GitWorkflowService.stashInfo", input.cwd).pipe( + Effect.andThen(git.stashInfo(input)), + ), renameBranch: (input) => ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( Effect.andThen(git.renameBranch(input)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8394897c48..6cc5bb24ba 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2897,6 +2897,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc vcs.stashAndSwitch errors after refreshing vcs status", () => + Effect.gen(function* () { + const gitError = new GitCommandError({ + operation: "stashAndSwitch", + command: "git stash pop", + cwd: "/tmp/repo", + detail: "stash pop conflict", + }); + const refreshed = yield* Deferred.make(); + let refreshCalls = 0; + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + stashAndSwitch: () => Effect.fail(gitError), + }, + vcsStatusBroadcaster: { + refreshStatus: (cwd: string) => + Effect.sync(() => { + assert.equal(cwd, "/tmp/repo"); + refreshCalls += 1; + }).pipe( + Effect.andThen(Deferred.succeed(refreshed, undefined)), + Effect.as({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsStashAndSwitch]({ + cwd: "/tmp/repo", + refName: "feature/demo", + }).pipe(Effect.result), + ), + ); + + assertFailure(result, gitError); + yield* Deferred.await(refreshed).pipe(Effect.timeout(Duration.seconds(1))); + assert.equal(refreshCalls, 1); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () => Effect.gen(function* () { const gitError = new GitCommandError({ diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index c07aeaa09f..3409564f8d 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -23,6 +23,10 @@ import { type VcsListRefsResult, type VcsPullResult, type VcsRemoveWorktreeInput, + type VcsStashAndSwitchInput, + type VcsStashDropInput, + type VcsStashInfoInput, + type VcsStashInfoResult, type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; @@ -212,6 +216,11 @@ export interface GitVcsDriverShape { readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; + readonly stashAndSwitch: (input: VcsStashAndSwitchInput) => Effect.Effect; + readonly stashDrop: (input: VcsStashDropInput) => Effect.Effect; + readonly stashInfo: ( + input: VcsStashInfoInput, + ) => Effect.Effect; readonly initRepo: (input: VcsInitInput) => Effect.Effect; readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 19f12862da..67c8d44d6f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,4 +1,5 @@ import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -16,7 +17,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type VcsRef } from "@t3tools/contracts"; +import { GitCheckoutDirtyWorktreeError, GitCommandError, type VcsRef } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -37,6 +38,7 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); @@ -46,6 +48,7 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +const isGitCheckoutDirtyWorktreeError = Schema.is(GitCheckoutDirtyWorktreeError); const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -65,6 +68,17 @@ type TraceTailState = { remainder: string; }; +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + class StatusRemoteRefreshCacheKey extends Data.Class<{ gitCommonDir: string; remoteName: string; @@ -297,6 +311,19 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): return refName.length > 0 ? refName : null; } +function dirtyWorktreeDetailsFromCause( + cause: unknown, +): { branch: string; conflictingFiles: ReadonlyArray } | undefined { + if (!isGitCheckoutDirtyWorktreeError(cause)) { + return undefined; + } + + return { + branch: cause.branch, + conflictingFiles: [...cause.conflictingFiles], + }; +} + function createGitCommandError( operation: string, cwd: string, @@ -304,15 +331,85 @@ function createGitCommandError( detail: string, cause?: unknown, ): GitCommandError { + const dirtyWorktree = dirtyWorktreeDetailsFromCause(cause); return new GitCommandError({ operation, command: commandLabel(args), cwd, detail, + ...(dirtyWorktree ? { dirtyWorktree } : {}), ...(cause !== undefined ? { cause } : {}), }); } +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); +} + +interface TrackedPathIndex { + readonly files: ReadonlySet; + readonly directories: ReadonlySet; +} + +function splitPathSegments(path: string): string[] { + return path + .trim() + .split("/") + .filter((segment) => segment.length > 0); +} + +function buildTrackedPathIndex(paths: readonly string[]): TrackedPathIndex { + const files = new Set(); + const directories = new Set(); + + for (const path of paths) { + const segments = splitPathSegments(path); + if (segments.length === 0) continue; + files.add(segments.join("/")); + + // Record only strict ancestor directories; the leaf is a file, not a dir. + let prefix = ""; + for (let i = 0; i < segments.length - 1; i++) { + prefix = prefix.length > 0 ? `${prefix}/${segments[i]!}` : segments[i]!; + directories.add(prefix); + } + } + + return { files, directories }; +} + +// Reports a conflict only when an ignored path would collide with git's +// checkout: same-path file collision, ignored file living at a path git wants +// to populate as a directory, or an ancestor of the ignored path being a +// tracked file (file/directory conflict). +function pathConflictsWithTrackedIndex(path: string, index: TrackedPathIndex): boolean { + const segments = splitPathSegments(path); + if (segments.length === 0) return false; + + const fullPath = segments.join("/"); + if (index.files.has(fullPath)) return true; + if (index.directories.has(fullPath)) return true; + + let prefix = ""; + for (let i = 0; i < segments.length - 1; i++) { + prefix = prefix.length > 0 ? `${prefix}/${segments[i]!}` : segments[i]!; + if (index.files.has(prefix)) return true; + } + + return false; +} + function quoteGitCommand(args: ReadonlyArray): string { return `git ${args.join(" ")}`; } @@ -327,6 +424,14 @@ function isMissingGitCwdError(error: GitCommandError): boolean { ); } +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, @@ -1997,85 +2102,133 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( - function* (input) { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitVcsDriver.switchRef.localInputExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.exitCode === 0)), - executeGit( - "GitVcsDriver.switchRef.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.exitCode === 0)), - ], - { concurrency: "unbounded" }, - ); + const resolveSwitchRefPlan = Effect.fn("resolveSwitchRefPlan")(function* (input: { + cwd: string; + refName: string; + }) { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.switchRef.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + executeGit( + "GitVcsDriver.switchRef.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ], + { concurrency: "unbounded" }, + ); + + const localTrackingBranch = remoteExists + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackingBranch", + input.cwd, + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.map((result) => + result.exitCode === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + : null, + ), + ) + : null; - const localTrackingBranch = remoteExists + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate ? yield* executeGit( - "GitVcsDriver.switchRef.localTrackingBranch", + "GitVcsDriver.switchRef.localTrackedBranchTargetExists", input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe( - Effect.map((result) => - result.exitCode === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) - : null, - ), - ) - : null; + ).pipe(Effect.map((result) => result.exitCode === 0)) + : false; - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); - const localTrackedBranchTargetExists = - remoteExists && localTrackedBranchCandidate - ? yield* executeGit( - "GitVcsDriver.switchRef.localTrackedBranchTargetExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.exitCode === 0)) - : false; - - const checkoutArgs = localInputExists + const checkoutArgs = localInputExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.refName] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.refName]; - - yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; - const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ - "branch", - "--show-current", - ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + const checkoutRef = localTrackingBranch ?? input.refName; + + return { checkoutArgs, checkoutRef }; + }); + + const switchRefWithArgs = Effect.fn("switchRefWithArgs")(function* ( + input: { cwd: string; refName: string }, + checkoutArgs: readonly string[], + ) { + const checkoutResult = yield* executeGit( + "GitVcsDriver.switchRef.checkout", + input.cwd, + checkoutArgs, + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (checkoutResult.exitCode !== 0) { + const dirtyFiles = parseDirtyWorktreeFiles(checkoutResult.stderr); + if (dirtyFiles && dirtyFiles.length > 0) { + return yield* new GitCheckoutDirtyWorktreeError({ + branch: input.refName, + cwd: input.cwd, + conflictingFiles: dirtyFiles, + }); + } + const stderr = checkoutResult.stderr.trim(); + return yield* createGitCommandError( + "GitVcsDriver.switchRef.checkout", + input.cwd, + checkoutArgs, + stderr.length > 0 ? stderr : "git checkout failed", + ); + } - return { refName }; + const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + + return { refName }; + }); + + const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( + function* (input) { + const { checkoutArgs } = yield* resolveSwitchRefPlan(input); + return yield* switchRefWithArgs(input, checkoutArgs).pipe( + Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) => + Effect.fail( + createGitCommandError( + "GitVcsDriver.switchRef.checkout", + input.cwd, + checkoutArgs, + e.message, + e, + ), + ), + ), + ); }, ); @@ -2093,6 +2246,240 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const stashAndSwitch: GitVcsDriver.GitVcsDriverShape["stashAndSwitch"] = (input) => + Effect.gen(function* () { + const { checkoutArgs, checkoutRef } = yield* resolveSwitchRefPlan(input); + + const cleanupFailedStashPop = () => + Effect.gen(function* () { + const stashFiles = yield* executeGit( + "GitVcsDriver.stashAndSwitch.stashFileList", + input.cwd, + ["stash", "show", "--include-untracked", "--name-only"], + { timeoutMs: 5_000, allowNonZeroExit: true }, + ); + yield* executeGit( + "GitVcsDriver.stashAndSwitch.resetIndex", + input.cwd, + ["reset", "HEAD"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ); + yield* executeGit( + "GitVcsDriver.stashAndSwitch.restoreWorktree", + input.cwd, + ["checkout", "--", "."], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + if (stashFiles.exitCode !== 0 || stashFiles.stdout.trim().length === 0) { + return; + } + + const filePaths = parseNonEmptyLineList(stashFiles.stdout); + if (filePaths.length === 0) { + return; + } + + yield* executeGit( + "GitVcsDriver.stashAndSwitch.cleanStashRemnants", + input.cwd, + ["clean", "-f", "--", ...filePaths], + { timeoutMs: 10_000, allowNonZeroExit: true }, + ); + }); + const readStashList = () => + runGitStdout("GitVcsDriver.stashAndSwitch.stashList", input.cwd, ["stash", "list"]).pipe( + Effect.map((stdout) => stdout.trim()), + ); + + const stashListBefore = yield* readStashList(); + + yield* executeGit( + "GitVcsDriver.stashAndSwitch.stash", + input.cwd, + ["stash", "push", "-u", "-m", `t3code: stash before switching to ${input.refName}`], + { timeoutMs: 15_000, fallbackErrorMessage: "git stash failed" }, + ); + const stashEntryCreated = (yield* readStashList()) !== stashListBefore; + + const ignoredFilesResult = yield* executeGit( + "GitVcsDriver.stashAndSwitch.ignoredFiles", + input.cwd, + ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + const ignoredFiles = + ignoredFilesResult.exitCode === 0 + ? splitNullSeparatedPaths(ignoredFilesResult.stdout, ignoredFilesResult.stdoutTruncated) + : []; + const ignoredCheckoutConflicts = + ignoredFiles.length === 0 + ? [] + : yield* executeGit( + "GitVcsDriver.stashAndSwitch.checkoutTree", + input.cwd, + ["ls-tree", "-r", "--name-only", "-z", checkoutRef], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ).pipe( + Effect.map((result) => + result.exitCode === 0 + ? splitNullSeparatedPaths(result.stdout, result.stdoutTruncated) + : [], + ), + Effect.map((trackedPaths) => { + if (trackedPaths.length === 0) { + return []; + } + + const trackedIndex = buildTrackedPathIndex(trackedPaths); + return ignoredFiles.filter((path) => + pathConflictsWithTrackedIndex(path, trackedIndex), + ); + }), + ); + + const dirtyWorktreeError = + ignoredCheckoutConflicts.length > 0 + ? new GitCheckoutDirtyWorktreeError({ + branch: checkoutRef, + cwd: input.cwd, + conflictingFiles: ignoredCheckoutConflicts, + }) + : null; + + const checkoutExit = yield* Effect.exit( + dirtyWorktreeError + ? Effect.fail( + createGitCommandError( + "GitVcsDriver.stashAndSwitch.checkout", + input.cwd, + ["checkout", input.refName], + dirtyWorktreeError.message, + dirtyWorktreeError, + ), + ) + : switchRefWithArgs(input, checkoutArgs).pipe( + Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) => + Effect.fail( + createGitCommandError( + "GitVcsDriver.stashAndSwitch.checkout", + input.cwd, + ["checkout", input.refName], + e.message, + e, + ), + ), + ), + ), + ); + if (Exit.isFailure(checkoutExit)) { + if (!stashEntryCreated) { + return yield* Effect.failCause(checkoutExit.cause); + } + + const restoreResult = yield* executeGit( + "GitVcsDriver.stashAndSwitch.restoreOriginalBranch", + input.cwd, + ["stash", "pop"], + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (restoreResult.exitCode !== 0) { + yield* cleanupFailedStashPop(); + return yield* createGitCommandError( + "GitVcsDriver.stashAndSwitch.checkout", + input.cwd, + ["checkout", input.refName], + "Branch switch failed after stashing. Your changes are saved in the stash.", + Cause.squash(checkoutExit.cause), + ); + } + + return yield* Effect.failCause(checkoutExit.cause); + } + + if (!stashEntryCreated) { + return; + } + + const popResult = yield* executeGit( + "GitVcsDriver.stashAndSwitch.stashPop", + input.cwd, + ["stash", "pop"], + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (popResult.exitCode !== 0) { + yield* cleanupFailedStashPop(); + return yield* createGitCommandError( + "GitVcsDriver.stashAndSwitch.stashPop", + input.cwd, + ["stash", "pop"], + "Stash could not be applied to this branch. Your changes are saved in the stash.", + ); + } + }); + + const stashDrop: GitVcsDriver.GitVcsDriverShape["stashDrop"] = (input) => + executeGit("GitVcsDriver.stashDrop", input.cwd, ["stash", "drop"], { + timeoutMs: 10_000, + fallbackErrorMessage: "git stash drop failed", + }).pipe(Effect.asVoid); + + const stashInfo: GitVcsDriver.GitVcsDriverShape["stashInfo"] = (input) => + Effect.gen(function* () { + const stashLine = (yield* runGitStdout("GitVcsDriver.stashInfo.list", input.cwd, [ + "stash", + "list", + "-n", + "1", + "--format=%gd%x09%gs", + ])).trim(); + const separatorIndex = stashLine.indexOf("\t"); + const stashRef = + separatorIndex >= 0 ? stashLine.slice(0, separatorIndex).trim() : stashLine.trim(); + const message = + separatorIndex >= 0 ? stashLine.slice(separatorIndex + 1).trim() : stashLine.trim(); + if (stashRef.length === 0 || message.length === 0) { + return yield* createGitCommandError( + "GitVcsDriver.stashInfo", + input.cwd, + ["stash", "list", "-n", "1", "--format=%gd%x09%gs"], + "No stash entry is available.", + ); + } + + const branchOutput = yield* runGitStdout("GitVcsDriver.stashInfo.branch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.catch(() => Effect.succeed(""))); + const filesOutput = yield* runGitStdout("GitVcsDriver.stashInfo.files", input.cwd, [ + "stash", + "show", + "--include-untracked", + "--name-only", + stashRef, + ]).pipe(Effect.catch(() => Effect.succeed(""))); + + return { + cwd: input.cwd, + branch: branchOutput.trim() || null, + stashRef, + message, + files: parseNonEmptyLineList(filesOutput), + }; + }); + const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, @@ -2137,6 +2524,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* renameBranch, createRef, switchRef, + stashAndSwitch, + stashDrop, + stashInfo, initRepo, listLocalBranchNames, }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 001c9baff9..6851d306f1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1084,6 +1084,22 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsStashAndSwitch]: (input) => + observeRpcEffect( + WS_METHODS.vcsStashAndSwitch, + gitWorkflow.stashAndSwitch(input).pipe(Effect.ensuring(refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsStashDrop]: (input) => + observeRpcEffect( + WS_METHODS.vcsStashDrop, + gitWorkflow.stashDrop(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsStashInfo]: (input) => + observeRpcEffect(WS_METHODS.vcsStashInfo, gitWorkflow.stashInfo(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.vcsInit]: (input) => observeRpcEffect( WS_METHODS.vcsInit, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2e30edfc02..d4eac8269a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,22 +1,22 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { + EnvironmentApi, + EnvironmentId, + ThreadId, + VcsRef, + VcsStashInfoResult, +} from "@t3tools/contracts"; +import { type QueryClient, useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon } from "lucide-react"; -import { - useCallback, - useDeferredValue, - useEffect, - useMemo, - useOptimistic, - useRef, - useState, - useTransition, -} from "react"; - +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } 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 { cn } from "../lib/utils"; @@ -44,6 +44,15 @@ import { ComboboxStatus, ComboboxTrigger, } from "./ui/combobox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; import { stackedThreadToast, toastManager } from "./ui/toast"; interface BranchToolbarBranchSelectorProps { @@ -59,10 +68,215 @@ interface BranchToolbarBranchSelectorProps { onComposerFocusRequest?: () => void; } +type StashDiscardDialogState = { + cwd: string; + error: string | null; + info: VcsStashInfoResult | null; + loading: boolean; +}; + function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +// Matches the server-side message produced by `GitCheckoutDirtyWorktreeError`. +// Files are emitted one per line prefixed with " - ", so we can parse them +// unambiguously even when a path contains a comma. +const DIRTY_WORKTREE_ERROR_PATTERN = /Uncommitted changes block checkout to ([^:\n]+):\n([\s\S]+)/; + +function readDirtyWorktreeDetails( + error: unknown, +): { branch: string; conflictingFiles: string[] } | null { + if (!error || typeof error !== "object" || !("dirtyWorktree" in error)) { + return null; + } + + const dirtyWorktree = (error as { dirtyWorktree?: unknown }).dirtyWorktree; + if (!dirtyWorktree || typeof dirtyWorktree !== "object") { + return null; + } + + const branch = + "branch" in dirtyWorktree ? (dirtyWorktree as { branch?: unknown }).branch : undefined; + const conflictingFiles = + "conflictingFiles" in dirtyWorktree + ? (dirtyWorktree as { conflictingFiles?: unknown }).conflictingFiles + : undefined; + if (typeof branch !== "string" || !Array.isArray(conflictingFiles)) { + return null; + } + if (!conflictingFiles.every((file) => typeof file === "string")) { + return null; + } + + return { + branch, + conflictingFiles: [...conflictingFiles], + }; +} + +function parseDirtyWorktreeError(error: unknown): { branch: string; files: string[] } | null { + // Structured field is authoritative — preserves exact file paths regardless + // of whether they contain delimiters used by the human-readable message. + const dirtyWorktree = readDirtyWorktreeDetails(error); + if (dirtyWorktree) { + return { + branch: dirtyWorktree.branch, + files: dirtyWorktree.conflictingFiles, + }; + } + + const detail = + error && + typeof error === "object" && + "detail" in error && + typeof (error as { detail?: unknown }).detail === "string" + ? (error as { detail: string }).detail + : error instanceof Error + ? error.message + : String(error); + const match = DIRTY_WORKTREE_ERROR_PATTERN.exec(detail); + if (!match?.[1] || !match[2]) return null; + const files = match[2] + .split("\n") + .map((line) => line.replace(/^\s*-\s*/, "").trim()) + .filter((line) => line.length > 0); + if (files.length === 0) return null; + return { + branch: match[1].trim(), + files, + }; +} + +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) => boolean; + onRequestDiscardStash: (input: { cwd: string }) => void; + }, +): void { + const dirtyWorktree = parseDirtyWorktreeError(error); + if (dirtyWorktree) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Uncommitted changes block checkout.", + description: formatDirtyWorktreeDescription(dirtyWorktree.files), + actionProps: { + children: "Stash & Switch", + onClick: () => { + const accepted = ctx.runBranchAction(async () => { + try { + await ctx.api.vcs.stashAndSwitch({ cwd: ctx.cwd, refName: 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( + stackedThreadToast({ + 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.onRequestDiscardStash({ cwd: ctx.cwd }); + }, + }, + }), + ); + } else if (parseDirtyWorktreeError(stashError)) { + toastManager.add( + stackedThreadToast({ + 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( + stackedThreadToast({ + type: "error", + title: "Failed to stash and switch.", + description: toBranchActionErrorMessage(stashError), + }), + ); + } + } + }); + if (!accepted) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Branch action already running.", + description: "Wait for the current branch action to finish, then try again.", + }), + ); + } + }, + }, + }), + ); + return; + } + if (isUnresolvedIndexError(error)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unresolved conflicts in the repository.", + description: toBranchActionErrorMessage(error), + }), + ); + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: ctx.fallbackTitle, + description: toBranchActionErrorMessage(error), + }), + ); +} + function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: "local" | "worktree"; @@ -199,6 +413,10 @@ export function BranchToolbarBranchSelector({ const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); + const [stashDiscardDialog, setStashDiscardDialog] = useState( + null, + ); + const [isDroppingStash, setIsDroppingStash] = useState(false); const deferredBranchQuery = useDeferredValue(branchQuery); const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); @@ -287,11 +505,19 @@ 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); + const [isBranchActionPending, setIsBranchActionPending] = useState(false); + const isBranchActionPendingRef = useRef(false); + // Preserve the optimistic value while a branch action is in flight. A mid- + // flight git status refresh can briefly report the pre-checkout branch, and + // syncing that back into `resolvedActiveBranch` would cause the trigger to + // flicker from the target branch → old branch → target branch. Only adopt + // the canonical value once the action settles (mimics useOptimistic/ + // useTransition semantics). + useEffect(() => { + if (isBranchActionPending) return; + setOptimisticBranch(canonicalActiveBranch); + }, [canonicalActiveBranch, isBranchActionPending]); const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending @@ -305,14 +531,90 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Branch actions // --------------------------------------------------------------------------- - const runBranchAction = (action: () => Promise) => { - startBranchActionTransition(async () => { - await action().catch(() => undefined); - await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) - .catch(() => undefined); + const runBranchAction = useCallback( + (action: () => Promise): boolean => { + if (isBranchActionPendingRef.current) { + return false; + } + + isBranchActionPendingRef.current = true; + setIsBranchActionPending(true); + + void (async () => { + try { + await action().catch(() => undefined); + await queryClient + .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) + .catch(() => undefined); + } finally { + isBranchActionPendingRef.current = false; + setIsBranchActionPending(false); + } + })(); + return true; + }, + [branchCwd, environmentId, queryClient], + ); + + const openStashDiscardDialog = useCallback( + (input: { cwd: string }) => { + const api = readEnvironmentApi(environmentId); + setStashDiscardDialog({ + cwd: input.cwd, + error: api ? null : "Environment API is unavailable.", + info: null, + loading: Boolean(api), + }); + if (!api) return; + + void api.vcs.stashInfo({ cwd: input.cwd }).then( + (info) => { + setStashDiscardDialog((current) => + current?.cwd === input.cwd + ? { ...current, error: null, info, loading: false } + : current, + ); + }, + (error) => { + setStashDiscardDialog((current) => + current?.cwd === input.cwd + ? { + ...current, + error: toBranchActionErrorMessage(error), + info: null, + loading: false, + } + : current, + ); + }, + ); + }, + [environmentId], + ); + + const discardStashFromDialog = useCallback(() => { + const dialog = stashDiscardDialog; + const api = readEnvironmentApi(environmentId); + if (!dialog || !api || isDroppingStash || isBranchActionPending) return; + + runBranchAction(async () => { + setIsDroppingStash(true); + try { + await api.vcs.stashDrop({ cwd: dialog.cwd }); + setStashDiscardDialog(null); + } catch (dropError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to drop stash.", + description: toBranchActionErrorMessage(dropError), + }), + ); + } finally { + setIsDroppingStash(false); + } }); - }; + }, [environmentId, isBranchActionPending, isDroppingStash, runBranchAction, stashDiscardDialog]); const selectBranch = (refName: VcsRef) => { const api = readEnvironmentApi(environmentId); @@ -360,13 +662,20 @@ export function BranchToolbarBranchSelector({ setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to switch ref.", - description: toBranchActionErrorMessage(error), - }), - ); + handleCheckoutError(error, { + api, + environmentId, + cwd: selectionTarget.checkoutCwd, + branch: refName.name, + queryClient, + onSuccess: () => { + setOptimisticBranch(selectedBranchName); + setThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); + }, + fallbackTitle: "Failed to checkout branch.", + runBranchAction, + onRequestDiscardStash: openStashDiscardDialog, + }); } }); }; @@ -392,13 +701,20 @@ export function BranchToolbarBranchSelector({ setThreadBranch(createBranchResult.refName, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to create and switch ref.", - 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, + onRequestDiscardStash: openStashDiscardDialog, + }); } }); }; @@ -571,72 +887,170 @@ export function BranchToolbarBranchSelector({ } return ( - { - if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { - return; - } - branchListRef.current?.scrollIndexIntoView?.({ - index: eventDetails.index, - animated: false, - }); - }} - onOpenChange={handleOpenChange} - open={isBranchMenuOpen} - value={resolvedActiveBranch} - > - } - className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} + <> + { + if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { + return; + } + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); + }} + onOpenChange={handleOpenChange} + open={isBranchMenuOpen} + value={resolvedActiveBranch} > - {triggerLabel} - - - -
- setBranchQuery(event.target.value)} - /> -
- No refs found. - - {shouldVirtualizeBranchList ? ( - - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - void fetchNextPage().catch(() => undefined); - } - }} - style={{ maxHeight: "14rem" }} + } + className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} + disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} + > + {triggerLabel} + + + +
+ setBranchQuery(event.target.value)} /> - - ) : ( - - {filteredBranchPickerItems.map((itemValue, index) => - renderPickerItem(itemValue, index), - )} - - )} - {branchStatusText ? {branchStatusText} : null} - - +
+ No refs found. + + {shouldVirtualizeBranchList ? ( + + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage().catch(() => undefined); + } + }} + style={{ maxHeight: "14rem" }} + /> + + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} + + )} + {branchStatusText ? {branchStatusText} : null} +
+
+ { + if (!open) { + setStashDiscardDialog(null); + setIsDroppingStash(false); + } + }} + > + + + Discard saved stash? + + This will permanently drop the stash entry that preserved your uncommitted changes. + + + + {stashDiscardDialog?.loading ? ( +

Loading stash details...

+ ) : stashDiscardDialog?.error ? ( +

+ {stashDiscardDialog.error} +

+ ) : stashDiscardDialog?.info ? ( + <> +
+
+ Branch + + {stashDiscardDialog.info.branch ?? currentGitBranch ?? "Detached HEAD"} + +
+
+ Worktree + + {stashDiscardDialog.info.cwd} + +
+
+ Stash + + {stashDiscardDialog.info.stashRef} + +
+
+ Name + {stashDiscardDialog.info.message} +
+
+
+

+ Changed files ({stashDiscardDialog.info.files.length}) +

+ {stashDiscardDialog.info.files.length > 0 ? ( +
    + {stashDiscardDialog.info.files.map((file) => ( +
  • + {file} +
  • + ))} +
+ ) : ( +

+ Git did not report changed file names for this stash. +

+ )} +
+ + ) : null} +
+ + + + +
+
+ ); } diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 0ebf0c8a7f..7599cd973e 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -273,7 +273,9 @@ function deriveToastBodyDescriptor(toast: { const secondaryActionVariant: NonNullable = toast.data?.secondaryActionVariant ?? "outline"; const copyErrorText = - toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton + (toast.type === "error" || toast.type === "warning") && + typeof toast.description === "string" && + !toast.data?.hideCopyButton ? toast.description : null; const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 9e7b0c939a..353ff16fa2 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -37,6 +37,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { removeWorktree: rpcClient.vcs.removeWorktree, createRef: rpcClient.vcs.createRef, switchRef: rpcClient.vcs.switchRef, + stashAndSwitch: rpcClient.vcs.stashAndSwitch, + stashDrop: rpcClient.vcs.stashDrop, + stashInfo: rpcClient.vcs.stashInfo, init: rpcClient.vcs.init, }, git: { diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 2f1ca624d9..dd0e277420 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -96,6 +96,9 @@ export interface WsRpcClient { readonly removeWorktree: RpcUnaryMethod; readonly createRef: RpcUnaryMethod; readonly switchRef: RpcUnaryMethod; + readonly stashAndSwitch: RpcUnaryMethod; + readonly stashDrop: RpcUnaryMethod; + readonly stashInfo: RpcUnaryMethod; readonly init: RpcUnaryMethod; }; /** @@ -214,6 +217,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), + stashAndSwitch: (input) => + transport.request((client) => client[WS_METHODS.vcsStashAndSwitch](input)), + stashDrop: (input) => transport.request((client) => client[WS_METHODS.vcsStashDrop](input)), + stashInfo: (input) => transport.request((client) => client[WS_METHODS.vcsStashInfo](input)), init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), }, git: { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 0b155bf49b..1eb377519d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -178,6 +178,22 @@ export const VcsSwitchRefInput = Schema.Struct({ }); export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type; +export const VcsStashAndSwitchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, +}); +export type VcsStashAndSwitchInput = typeof VcsStashAndSwitchInput.Type; + +export const VcsStashDropInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsStashDropInput = typeof VcsStashDropInput.Type; + +export const VcsStashInfoInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsStashInfoInput = typeof VcsStashInfoInput.Type; + export const VcsInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, kind: Schema.optional(VcsDriverKind), @@ -280,6 +296,15 @@ export const VcsSwitchRefResult = Schema.Struct({ }); export type VcsSwitchRefResult = typeof VcsSwitchRefResult.Type; +export const VcsStashInfoResult = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + stashRef: TrimmedNonEmptyStringSchema, + message: TrimmedNonEmptyStringSchema, + files: Schema.Array(TrimmedNonEmptyStringSchema), +}); +export type VcsStashInfoResult = typeof VcsStashInfoResult.Type; + export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, branch: Schema.Struct({ @@ -317,11 +342,18 @@ export const VcsPullResult = Schema.Struct({ export type VcsPullResult = typeof VcsPullResult.Type; // RPC / domain errors +export const GitDirtyWorktreeDetails = Schema.Struct({ + branch: Schema.String, + conflictingFiles: Schema.Array(Schema.String), +}); +export type GitDirtyWorktreeDetails = typeof GitDirtyWorktreeDetails.Type; + export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { operation: Schema.String, command: Schema.String, cwd: Schema.String, detail: Schema.String, + dirtyWorktree: Schema.optional(GitDirtyWorktreeDetails), cause: Schema.optional(Schema.Defect), }) { override get message(): string { @@ -352,11 +384,29 @@ 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 { + // Use a newline-separated list so that file paths containing a + // `", "` sequence round-trip safely through the error message. The + // structured `conflictingFiles` field remains the authoritative source. + const fileList = this.conflictingFiles.map((file) => ` - ${file}`).join("\n"); + return `Uncommitted changes block checkout to ${this.branch}:\n${fileList}`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, GitCommandError, SourceControlProviderError, TextGenerationError, + GitCheckoutDirtyWorktreeError, ]); export type GitManagerServiceError = typeof GitManagerServiceError.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 58894adac1..ebe9c9e76b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -13,6 +13,10 @@ import type { VcsPullInput, VcsPullResult, VcsRemoveWorktreeInput, + VcsStashAndSwitchInput, + VcsStashDropInput, + VcsStashInfoInput, + VcsStashInfoResult, GitResolvePullRequestResult, VcsStatusInput, VcsStatusResult, @@ -522,6 +526,9 @@ export interface EnvironmentApi { removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; createRef: (input: VcsCreateRefInput) => Promise; switchRef: (input: VcsSwitchRefInput) => Promise; + stashAndSwitch: (input: VcsStashAndSwitchInput) => Promise; + stashDrop: (input: VcsStashDropInput) => Promise; + stashInfo: (input: VcsStashInfoInput) => Promise; init: (input: VcsInitInput) => Promise; pull: (input: VcsPullInput) => Promise; refreshStatus: (input: VcsStatusInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 705621b5da..125f7ceed4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -11,6 +11,7 @@ import { } from "./filesystem.ts"; import { GitActionProgressEvent, + GitCheckoutDirtyWorktreeError, VcsSwitchRefInput, VcsSwitchRefResult, GitCommandError, @@ -30,6 +31,10 @@ import { VcsRemoveWorktreeInput, GitResolvePullRequestResult, GitRunStackedActionInput, + VcsStashAndSwitchInput, + VcsStashDropInput, + VcsStashInfoInput, + VcsStashInfoResult, VcsStatusInput, VcsStatusResult, VcsStatusStreamEvent, @@ -119,6 +124,9 @@ export const WS_METHODS = { vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", + vcsStashAndSwitch: "vcs.stashAndSwitch", + vcsStashDrop: "vcs.stashDrop", + vcsStashInfo: "vcs.stashInfo", vcsInit: "vcs.init", // Git workflow methods @@ -340,6 +348,22 @@ export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { payload: VcsSwitchRefInput, success: VcsSwitchRefResult, + error: Schema.Union([GitCommandError, GitCheckoutDirtyWorktreeError]), +}); + +export const WsVcsStashAndSwitchRpc = Rpc.make(WS_METHODS.vcsStashAndSwitch, { + payload: VcsStashAndSwitchInput, + error: GitCommandError, +}); + +export const WsVcsStashDropRpc = Rpc.make(WS_METHODS.vcsStashDrop, { + payload: VcsStashDropInput, + error: GitCommandError, +}); + +export const WsVcsStashInfoRpc = Rpc.make(WS_METHODS.vcsStashInfo, { + payload: VcsStashInfoInput, + success: VcsStashInfoResult, error: GitCommandError, }); @@ -491,6 +515,9 @@ export const WsRpcGroup = RpcGroup.make( WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, WsVcsSwitchRefRpc, + WsVcsStashAndSwitchRpc, + WsVcsStashDropRpc, + WsVcsStashInfoRpc, WsVcsInitRpc, WsTerminalOpenRpc, WsTerminalWriteRpc,