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
15 changes: 15 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof GitVcsDriver.makeVcsDriverShape>>,
cwd: string,
Expand Down
29 changes: 26 additions & 3 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process";
import {
GitCommandError,
VcsProcessExitError,
VcsRepositoryDetectionError,
VcsUnsupportedOperationError,
type VcsSwitchRefInput,
type VcsSwitchRefResult,
Expand Down Expand Up @@ -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({
Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(<AppRoot router={router} />, { 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]);
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
CloudDownloadIcon,
CloudUploadIcon,
FileDiffIcon,
FolderXIcon,
GitBranchPlusIcon,
GitCommitIcon,
InfoIcon,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1558,7 +1569,13 @@ export default function GitActionsControl({

return (
<>
{!showActions ? null : !isRepo ? (
{!showActions ? null : !isRepo && isWorktreeThread ? (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// A worktree is always a checkout, so a non-repository status means it was removed.
<ThreadDetailsControl size="xs" variant="ghost" part="row" panel tone="muted" disabled>
<FolderXIcon className="size-3.5" aria-hidden />
<span className={cn("ml-0.5", THREAD_DETAILS_PANEL_LABEL_CLASS)}>Worktree missing</span>
</ThreadDetailsControl>
) : !isRepo ? (
<ThreadDetailsControl
size="xs"
variant="ghost"
Expand Down
53 changes: 41 additions & 12 deletions apps/web/test/fakeEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
TurnItemId,
WS_METHODS,
WsRpcGroup,
type ServerConfigStreamEvent,
type VcsStatusStreamEvent,
type OrchestrationProjectShell,
type OrchestrationV2Command,
Expand Down Expand Up @@ -198,6 +199,7 @@ export interface FakeThread {
readonly olderSettled?: true;
readonly id: ThreadId;
readonly title: string;
readonly worktreePath?: string;
readonly runs: ReadonlyArray<OrchestrationV2Run>;
readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -332,7 +334,8 @@ let threads: ReadonlyArray<FakeThread> = [];
let dispatched: OrchestrationV2Command[] = [];
let acceptCommands = false;
let sequence = 1;
let vcsStatusSubscriber: Queue.Queue<VcsStatusStreamEvent> | null = null;
const vcsStatusSubscribers = new Map<string, Queue.Queue<VcsStatusStreamEvent>>();
let serverConfigSubscriber: Queue.Queue<ServerConfigStreamEvent> | null = null;

const subscribe = <A>(first: ReadonlyArray<A>, register?: (queue: Queue.Queue<A>) => () => void) =>
Stream.unwrap(
Expand All @@ -347,6 +350,17 @@ const subscribe = <A>(first: ReadonlyArray<A>, register?: (queue: Queue.Queue<A>
}),
);

const subscribeServerConfig = () =>
subscribe<ServerConfigStreamEvent>(
[{ version: 1, type: "snapshot", config: serverConfig() }],
(queue) => {
serverConfigSubscriber = queue;
return () => {
if (serverConfigSubscriber === queue) serverConfigSubscriber = null;
};
},
);

const handlers: Record<string, (input: never) => 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.
Expand Down Expand Up @@ -404,15 +418,15 @@ const handlers: Record<string, (input: never) => unknown> = {
: [],
});
},
[WS_METHODS.subscribeVcsStatus]: () =>
[WS_METHODS.subscribeVcsStatus]: ({ cwd }: { readonly cwd: string }) =>
subscribe<VcsStatusStreamEvent>([], (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.
Expand All @@ -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),
Expand Down Expand Up @@ -541,7 +554,23 @@ export function runningRun(threadId: ThreadId, id: string, ordinal: number, user

export { userItem };

export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise<void> {
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<ServerConfig>): Promise<void> {
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<void> {
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));
}
Loading