diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 89580e0937af7..de4d9a12c5b4e 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -152,6 +152,21 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("refuses to initialize a repository in a folder that does not exist", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-init-missing-" }); + const cwd = path.join(parent, "removed-worktree"); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + const error = yield* driver.initRepository({ cwd }).pipe(Effect.flip); + + assert.match(error.message, /does not exist/i); + assert.isFalse(yield* fileSystem.exists(cwd)); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( driver: Effect.Success>, cwd: string, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index dbc87e088d905..db2b109e708aa 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process"; import { GitCommandError, VcsProcessExitError, + VcsRepositoryDetectionError, VcsUnsupportedOperationError, type VcsSwitchRefInput, type VcsSwitchRefResult, @@ -823,11 +824,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); }); - const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) => - gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { + const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = Effect.fn( + "GitVcsDriver.initRepository", + )(function* (input) { + // A deleted folder reports as a non-repository; say so instead of git's bare exit 128. + const exists = yield* fileSystem.exists(input.cwd).pipe( + Effect.mapError( + (cause) => + new VcsRepositoryDetectionError({ + operation: "GitVcsDriver.initRepository", + cwd: input.cwd, + detail: "Could not check that the folder exists.", + cause, + }), + ), + ); + if (!exists) { + return yield* new VcsRepositoryDetectionError({ + operation: "GitVcsDriver.initRepository", + cwd: input.cwd, + detail: "The folder does not exist.", + }); + } + yield* gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, - }).pipe(Effect.asVoid); + }); + }); const resolveHeadCommit = (cwd: string) => execute({ diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 32ccf9465ba3e..b9eda7af5aacb 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -18,9 +18,12 @@ import { getRouter } from "~/router"; import { ENVIRONMENT_ID, PROJECT_ID, + emitServerConfig, + emitVcsStatus, installFakeEnvironment, threadWithHistory, type FakeDriver, + type FakeThread, } from "../../test/fakeEnvironment"; import { act, render } from "../../test/render"; @@ -69,6 +72,79 @@ it("a new draft does not load its reserved thread before the server lists it", a expect(threadRequests).toEqual([]); }); +async function openThreadDetailsOnNonRepository(thread: FakeThread & { worktreePath: string }) { + const stack = new AsyncDisposableStack(); + try { + stack.use(installFakeEnvironment([thread])); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const threadRef = scopeThreadRef(ENVIRONMENT_ID, thread.id); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", true); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", true); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + const container = document.createElement("div"); + container.style.cssText = "height: 900px; width: 1400px;"; + // AppRoot brings its own router. + stack.use(await render(, { container, router: false })); + await expect.element(page.getByText(`${thread.id}: question 0`)).toBeVisible(); + // vcs.status reports a missing folder as a non-repository. + await vi.waitFor(() => + act(() => + emitVcsStatus( + { + _tag: "snapshot", + local: { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + remote: null, + }, + thread.worktreePath, + ), + ), + ); + return stack.move(); + } catch (error) { + await stack.disposeAsync(); + throw error; + } +} + +it("does not offer Initialize Git when the thread's worktree folder is missing", async () => { + await using _app = await openThreadDetailsOnNonRepository({ + ...threadWithHistory("worktree-thread", 1), + worktreePath: "/workspace/worktrees/render/t3-removed", + }); + + await expect.element(page.getByText("Worktree missing")).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Initialize Git" })) + .not.toBeInTheDocument(); +}); + +it("keeps Initialize Git for a No project thread's scratch folder", async () => { + // No project threads store their plain scratch folder as worktreePath. + await using _app = await openThreadDetailsOnNonRepository({ + ...threadWithHistory("scratch-thread", 1), + worktreePath: "/workspace/render/2026-11-02-x", + }); + // The fake connection outlives each test, so the config is pushed rather than installed. + await using _scratch = { [Symbol.asyncDispose]: () => act(() => emitServerConfig()) }; + await act(() => emitServerConfig({ scratchWorkspaceRoot: "/workspace/render" })); + + await expect.element(page.getByRole("button", { name: "Initialize Git" })).toBeVisible(); + await expect.element(page.getByText("Worktree missing")).not.toBeInTheDocument(); +}); + it("opens the thread actions menu for an older settled thread the shell leaves out", async () => { const thread = { ...threadWithHistory("older-settled", 1), olderSettled: true as const }; using _environment = installFakeEnvironment([thread]); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index a9193f8fd35d9..ff4c5f68536ee 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -31,6 +31,7 @@ import { CloudDownloadIcon, CloudUploadIcon, FileDiffIcon, + FolderXIcon, GitBranchPlusIcon, GitCommitIcon, InfoIcon, @@ -91,7 +92,8 @@ import { useVcsInitAction, useVcsPullAction, } from "../state/sourceControlActions"; -import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { useProject, useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { useFilesystemReadAccess } from "~/state/filesystem"; @@ -977,6 +979,15 @@ export default function GitActionsControl({ const activeServerThread = activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null; const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null; + const activeThreadContext = activeServerThread ?? activeDraftThread; + const activeProject = useProject( + activeEnvironmentId !== null && activeThreadContext !== null + ? scopeProjectRef(activeEnvironmentId, activeThreadContext.projectId) + : null, + ); + // No project threads keep a plain scratch folder in worktreePath, not a checkout. + const isWorktreeThread = + activeThreadContext?.worktreePath != null && activeProject !== null && !activeProject.isScratch; const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); @@ -1558,7 +1569,13 @@ export default function GitActionsControl({ return ( <> - {!showActions ? null : !isRepo ? ( + {!showActions ? null : !isRepo && isWorktreeThread ? ( + // A worktree is always a checkout, so a non-repository status means it was removed. + + + Worktree missing + + ) : !isRepo ? ( ; readonly items: ReadonlyArray; } @@ -236,7 +238,7 @@ function shellOf(thread: FakeThread): OrchestrationV2ThreadShell { runtimeMode: "full-access", interactionMode: "default", branch: "main", - worktreePath: null, + worktreePath: thread.worktreePath ?? null, activeProviderThreadId: null, lineage: { rootThreadId: thread.sideChatOf ?? thread.id, @@ -332,7 +334,8 @@ let threads: ReadonlyArray = []; let dispatched: OrchestrationV2Command[] = []; let acceptCommands = false; let sequence = 1; -let vcsStatusSubscriber: Queue.Queue | null = null; +const vcsStatusSubscribers = new Map>(); +let serverConfigSubscriber: Queue.Queue | null = null; const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue) => () => void) => Stream.unwrap( @@ -347,6 +350,17 @@ const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue }), ); +const subscribeServerConfig = () => + subscribe( + [{ version: 1, type: "snapshot", config: serverConfig() }], + (queue) => { + serverConfigSubscriber = queue; + return () => { + if (serverConfigSubscriber === queue) serverConfigSubscriber = null; + }; + }, + ); + const handlers: Record unknown> = { // Recorded, and accepted only when the scenario asks, since a pending command holds every // later command on its thread. Accepted commands' events never arrive. @@ -404,15 +418,15 @@ const handlers: Record unknown> = { : [], }); }, - [WS_METHODS.subscribeVcsStatus]: () => + [WS_METHODS.subscribeVcsStatus]: ({ cwd }: { readonly cwd: string }) => subscribe([], (queue) => { - vcsStatusSubscriber = queue; + vcsStatusSubscribers.delete(cwd); + vcsStatusSubscribers.set(cwd, queue); return () => { - if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null; + if (vcsStatusSubscribers.get(cwd) === queue) vcsStatusSubscribers.delete(cwd); }; }), - [WS_METHODS.subscribeServerConfig]: () => - subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig() }]), + [WS_METHODS.subscribeServerConfig]: () => subscribeServerConfig(), }; // Unanswered calls stay pending, like a server that has not replied yet. @@ -428,8 +442,7 @@ const client = new Proxy(handlers, { const session: EnvironmentConnectionLease["session"] = { client: client as unknown as WsRpcProtocolClient, initialConfig: Effect.sync(serverConfig), - subscribeServerConfig: () => - subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig() }]), + subscribeServerConfig: () => subscribeServerConfig(), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -541,7 +554,23 @@ export function runningRun(threadId: ThreadId, id: string, ordinal: number, user export { userItem }; -export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise { - if (vcsStatusSubscriber === null) throw new Error("No VCS status subscription"); - await Effect.runPromise(Queue.offer(vcsStatusSubscriber, event)); +/** Replaces the server config the app holds; `undefined` restores the default. */ +export async function emitServerConfig(overrides?: Partial): Promise { + if (serverConfigSubscriber === null) throw new Error("No server config subscription"); + await Effect.runPromise( + Queue.offer(serverConfigSubscriber, { + version: 1, + type: "snapshot", + config: { ...serverConfig(), ...overrides }, + }), + ); +} + +/** Sends to the subscription for `cwd`, or to the most recent one. */ +export async function emitVcsStatus(event: VcsStatusStreamEvent, cwd?: string): Promise { + const subscriber = + cwd === undefined ? [...vcsStatusSubscribers.values()].at(-1) : vcsStatusSubscribers.get(cwd); + if (subscriber === undefined) + throw new Error(`No VCS status subscription for ${cwd ?? "any cwd"}`); + await Effect.runPromise(Queue.offer(subscriber, event)); }