From fb55a3615c1c51473f3b87faf54c342c85b337e6 Mon Sep 17 00:00:00 2001 From: owen <185341197+oxwen11@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:26:12 -0400 Subject: [PATCH] Treat git workspace availability as data, not a failed probe. Non-git directories and missing folders return kind tags from git.branch; only a real git execution failure stays GIT_FAILED. The composer labels those states and blocks send when the workspace is gone. Co-authored-by: Cursor --- .../chat/components/chat-input-composer.tsx | 25 ++++++--- packages/contract/src/git.ts | 28 +++++++++- packages/server/src/errors.ts | 5 ++ packages/server/src/git/service.ts | 55 +++++++++++++++---- packages/server/src/rpc/git.ts | 13 +++-- packages/server/test/git.test.ts | 18 +++++- packages/server/test/rpc-git.test.ts | 16 +++++- 7 files changed, 127 insertions(+), 33 deletions(-) diff --git a/apps/app/src/features/chat/components/chat-input-composer.tsx b/apps/app/src/features/chat/components/chat-input-composer.tsx index 8dd0a21e6..7bea9bf63 100644 --- a/apps/app/src/features/chat/components/chat-input-composer.tsx +++ b/apps/app/src/features/chat/components/chat-input-composer.tsx @@ -14,6 +14,7 @@ import { type ReactNode, useState } from "react"; import { useStore } from "zustand"; import type { OutgoingMessage } from "@/features/chat/runtime/chat-state"; +import { useLatestRef } from "@/hooks/use-latest-ref"; import { useChatSession } from "./chat-session-context"; import { ChatInput } from "./input/chat-input"; @@ -146,13 +147,15 @@ export function ChatInputComposer({ ...orpcQueryUtils.git.branch.queryOptions({ input: { cwd: cwd ?? "" } }), enabled: cwd !== undefined, }); - const currentBranch = branch.data?.current; + const currentBranch = branch.data?.kind === "repository" ? branch.data.current : undefined; + const workspaceUnavailable = branch.data?.kind === "workspace-unavailable"; const { acknowledgeRecovery, prompt, steer, turnInProgress, store } = useChatSession(); const status = useStore(store, (state) => state.session.status); const activeTurnId = useStore(store, (state) => state.session.activeTurnId); const outgoing = useStore(store, (state) => state.outgoing); const recovery = useStore(store, (state) => state.recovery.snapshot); const [acknowledgementError, setAcknowledgementError] = useState(null); + const workspaceUnavailableRef = useLatestRef(workspaceUnavailable); const controller = useChatInputController({ extensions: (self) => [ @@ -160,7 +163,7 @@ export function ChatInputComposer({ createSubmitKeymap({ onSubmit: () => void self.submit() }), ], onSubmit: (text) => { - if (recovery !== null) return false; + if (recovery !== null || workspaceUnavailableRef.current) return false; // The composer clears once the message is accepted into the local queue; // the promise may settle later when this item reaches the server. void prompt(text).catch(() => undefined); @@ -227,7 +230,7 @@ export function ChatInputComposer({ {toolbar} - + {branch.isPending ? ( diff --git a/packages/contract/src/git.ts b/packages/contract/src/git.ts index da5436d1e..5401e87cd 100644 --- a/packages/contract/src/git.ts +++ b/packages/contract/src/git.ts @@ -4,13 +4,35 @@ import { Schema } from "effect"; import { toStandardSchema } from "./domain"; const CwdInput = Schema.Struct({ cwd: Schema.String }); +const cwdData = toStandardSchema(CwdInput); -/** Current HEAD name when the workspace is a git work tree; otherwise null. */ -export const GitBranchSchema = Schema.Struct({ +export const GitRepositoryBranchSchema = Schema.Struct({ + kind: Schema.Literal("repository"), current: Schema.Union([Schema.String, Schema.Null]), }); +export type GitRepositoryBranch = typeof GitRepositoryBranchSchema.Type; + +/** Repository availability plus the current branch when the workspace is a readable Git work tree. */ +export const GitBranchSchema = Schema.Union([ + GitRepositoryBranchSchema, + Schema.Struct({ kind: Schema.Literal("not-repository") }), + Schema.Struct({ kind: Schema.Literal("workspace-unavailable") }), +]); export type GitBranch = typeof GitBranchSchema.Type; +export function isGitRepositoryBranch( + branch: GitBranch | undefined, +): branch is GitRepositoryBranch { + return branch?.kind === "repository"; +} + +const branchErrors = { + GIT_FAILED: { data: cwdData }, +}; + export const gitContract = { - branch: oc.input(toStandardSchema(CwdInput)).output(toStandardSchema(GitBranchSchema)), + branch: oc + .input(toStandardSchema(CwdInput)) + .errors(branchErrors) + .output(toStandardSchema(GitBranchSchema)), }; diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 19392378b..194be2c42 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -20,6 +20,11 @@ export class GitError extends Data.TaggedError("GitError")<{ readonly cause: unknown; }> {} +/** `cwd` exists but is not a Git work tree. `git.branch` models this as data. */ +export class GitNotRepository extends Data.TaggedError("GitNotRepository")<{ + readonly cwd: string; +}> {} + export class SessionNotFound extends Data.TaggedError("SessionNotFound")<{ readonly projectId: string; readonly sessionId: string; diff --git a/packages/server/src/git/service.ts b/packages/server/src/git/service.ts index ece9153fa..30930ed3a 100644 --- a/packages/server/src/git/service.ts +++ b/packages/server/src/git/service.ts @@ -1,19 +1,36 @@ +import fs from "node:fs/promises"; + +import type { GitBranch } from "@vibest/contract/git"; import { Context, Effect, Layer } from "effect"; import { type BranchSummary, simpleGit, type StatusResult } from "simple-git"; -import { GitError } from "../errors"; +import { GitError, GitNotRepository, WorkspaceNotDirectory, WorkspaceReadError } from "../errors"; + +const isNotRepositoryMessage = (cause: unknown): boolean => { + const message = cause instanceof Error ? cause.message : String(cause); + return /not a git repository/i.test(message); +}; + +const probeWorkspace = (cwd: string) => + Effect.tryPromise({ + try: () => fs.stat(cwd), + catch: (cause) => new WorkspaceReadError({ path: cwd, cause }), + }).pipe( + Effect.flatMap((info) => + info.isDirectory() ? Effect.void : Effect.fail(new WorkspaceNotDirectory({ path: cwd })), + ), + ); /** - * `git` module — read-only, delegating to the `git` CLI via simple-git. Returns - * simple-git's own result types (`StatusResult`, `BranchSummary`) rather than - * re-modelling them. Only `status`/`branch` are exposed for now (design §4.5 / - * §8). + * `git` module — read-only, delegating to the `git` CLI via simple-git. + * `status` returns simple-git's `StatusResult`. `branch` classifies workspace + * availability so a missing `.git` is data, not an RPC error. */ export class GitService extends Context.Service< GitService, { readonly status: (dir: string) => Effect.Effect; - readonly branch: (dir: string) => Effect.Effect; + readonly branch: (dir: string) => Effect.Effect; } >()("GitService") {} @@ -24,9 +41,25 @@ export const GitServiceLayer: Layer.Layer = Layer.sync(GitService, ( catch: (cause) => new GitError({ cause }), }), - branch: (dir) => - Effect.tryPromise({ - try: () => simpleGit(dir).branch(), - catch: (cause) => new GitError({ cause }), - }), + branch: (cwd) => + probeWorkspace(cwd).pipe( + Effect.andThen( + Effect.tryPromise({ + try: () => simpleGit(cwd).branch(), + catch: (cause) => + isNotRepositoryMessage(cause) ? new GitNotRepository({ cwd }) : new GitError({ cause }), + }), + ), + Effect.map( + (summary: BranchSummary): GitBranch => ({ + kind: "repository", + current: summary.current ?? null, + }), + ), + Effect.catchTags({ + GitNotRepository: () => Effect.succeed({ kind: "not-repository" as const }), + WorkspaceNotDirectory: () => Effect.succeed({ kind: "workspace-unavailable" as const }), + WorkspaceReadError: () => Effect.succeed({ kind: "workspace-unavailable" as const }), + }), + ), })); diff --git a/packages/server/src/rpc/git.ts b/packages/server/src/rpc/git.ts index 23a22fe1b..cbcac22bd 100644 --- a/packages/server/src/rpc/git.ts +++ b/packages/server/src/rpc/git.ts @@ -9,12 +9,15 @@ import type { RpcContext } from "./context"; const orpc = implement(gitContract).$context(); export const gitRouter = orpc.router({ - branch: orpc.branch.effect(function* ({ input }) { + branch: orpc.branch.effect(function* ({ input, errors }) { const git = yield* GitService; - return yield* git.branch(input.cwd).pipe( - Effect.map((summary) => ({ current: summary.current ?? null })), - Effect.catchTag("GitError", () => Effect.succeed({ current: null })), - ); + return yield* git + .branch(input.cwd) + .pipe( + Effect.catchTag("GitError", () => + Effect.fail(errors.GIT_FAILED({ data: { cwd: input.cwd } })), + ), + ); }), }); diff --git a/packages/server/test/git.test.ts b/packages/server/test/git.test.ts index 71543108b..f106adab1 100644 --- a/packages/server/test/git.test.ts +++ b/packages/server/test/git.test.ts @@ -38,13 +38,27 @@ layer(NodePlatformLayer)("GitService", (it) => { }).pipe(Effect.provide(GitServiceLayer)), ); - it.effect("lists branches", () => + it.effect("lists the current branch of a repository", () => Effect.gen(function* () { const dir = yield* repo; const git = yield* GitService; const branch = yield* git.branch(dir); + assert.equal(branch.kind, "repository"); + if (branch.kind !== "repository") return; assert.equal(branch.current, "main"); - assert.ok(branch.all.includes("main")); + }).pipe(Effect.provide(GitServiceLayer)), + ); + + it.effect("models branch availability without turning it into a failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "vibest-not-git-" }); + const git = yield* GitService; + + assert.deepEqual(yield* git.branch(dir), { kind: "not-repository" }); + assert.deepEqual(yield* git.branch(path.join(dir, "missing")), { + kind: "workspace-unavailable", + }); }).pipe(Effect.provide(GitServiceLayer)), ); }); diff --git a/packages/server/test/rpc-git.test.ts b/packages/server/test/rpc-git.test.ts index 6fb74514f..f474ade1a 100644 --- a/packages/server/test/rpc-git.test.ts +++ b/packages/server/test/rpc-git.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest"; import { makeRpcTestHarness } from "./rpc-harness"; describe("git router", () => { - it("returns the current branch for a work tree and null otherwise", async () => { + it("models repository availability instead of failing the probe", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-home-")); const repo = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-git-")); const bare = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-nogit-")); @@ -22,8 +22,18 @@ describe("git router", () => { const harness = await makeRpcTestHarness(home); try { - await expect(harness.client.git.branch({ cwd: repo })).resolves.toEqual({ current: "main" }); - await expect(harness.client.git.branch({ cwd: bare })).resolves.toEqual({ current: null }); + await expect(harness.client.git.branch({ cwd: repo })).resolves.toEqual({ + kind: "repository", + current: "main", + }); + await expect(harness.client.git.branch({ cwd: bare })).resolves.toEqual({ + kind: "not-repository", + }); + await expect(harness.client.git.branch({ cwd: path.join(bare, "missing") })).resolves.toEqual( + { + kind: "workspace-unavailable", + }, + ); } finally { await harness.dispose(); }