Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions apps/app/src/features/chat/components/chat-input-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -146,21 +147,23 @@ 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<string | null>(null);
const workspaceUnavailableRef = useLatestRef(workspaceUnavailable);

const controller = useChatInputController({
extensions: (self) => [
...createChatBaseExtensions(),
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);
Expand Down Expand Up @@ -227,7 +230,7 @@ export function ChatInputComposer({
<PromptInputToolbar>
<PromptInputTools>{toolbar}</PromptInputTools>
<PromptInputSubmit
disabled={!hasContent || recovery !== null}
disabled={!hasContent || recovery !== null || workspaceUnavailable}
// During a turn this button enqueues rather than interrupts, so the
// send arrow is the truthful affordance instead of the stop square.
status={turnInProgress ? "ready" : status}
Expand All @@ -236,17 +239,21 @@ export function ChatInputComposer({
</ChatInputProvider>
</Card>
<CardFrameFooter className="px-3 py-2">
<span
className="text-muted-foreground flex h-4 min-w-0 items-center gap-1.5 text-xs"
title={currentBranch ? "Current git branch" : undefined}
>
<span className="flex h-4 min-w-0 items-center text-xs">
{branch.isPending ? (
<span aria-hidden="true" className="bg-muted h-2 w-24 animate-pulse rounded-sm" />
) : currentBranch ? (
<>
<span
className="text-muted-foreground flex min-w-0 items-center gap-1.5"
title="Current git branch"
>
<GitBranchIcon aria-hidden="true" className="size-3.5 shrink-0" />
<span className="truncate">{currentBranch}</span>
</>
</span>
) : branch.data?.kind === "not-repository" ? (
<span className="text-muted-foreground">Not a Git repository</span>
) : workspaceUnavailable ? (
<span className="text-destructive">Workspace unavailable</span>
) : null}
</span>
</CardFrameFooter>
Expand Down
28 changes: 25 additions & 3 deletions packages/contract/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
5 changes: 5 additions & 0 deletions packages/server/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 44 additions & 11 deletions packages/server/src/git/service.ts
Original file line number Diff line number Diff line change
@@ -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<StatusResult, GitError>;
readonly branch: (dir: string) => Effect.Effect<BranchSummary, GitError>;
readonly branch: (dir: string) => Effect.Effect<GitBranch, GitError>;
}
>()("GitService") {}

Expand All @@ -24,9 +41,25 @@ export const GitServiceLayer: Layer.Layer<GitService> = 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 }),
}),
),
}));
13 changes: 8 additions & 5 deletions packages/server/src/rpc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import type { RpcContext } from "./context";
const orpc = implement(gitContract).$context<RpcContext>();

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 } })),
),
);
}),
});

Expand Down
18 changes: 16 additions & 2 deletions packages/server/test/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);
});
16 changes: 13 additions & 3 deletions packages/server/test/rpc-git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand All @@ -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();
}
Expand Down