Skip to content
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 @@ -132,6 +132,21 @@ it.effect("lists the fetch and push URLs of a partial clone remote", () =>
}).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 @@ -776,11 +777,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
86 changes: 84 additions & 2 deletions apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createMemoryHistory } from "@tanstack/react-router";
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { ThreadId } from "@t3tools/contracts";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
Expand All @@ -12,14 +12,19 @@ import { AppRoot } from "~/AppRoot";
import "~/components/ChatView";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { useRightPanelStore } from "~/rightPanelStore";
import { getRouter } from "~/router";
import {
ENVIRONMENT_ID,
PROJECT_ID,
emitServerConfig,
emitVcsStatus,
installFakeEnvironment,
threadWithHistory,
type FakeDriver,
type FakeThread,
} from "../../test/fakeEnvironment";
import { render } from "../../test/render";
import { act, render } from "../../test/render";

// The socket is the only fake layer; see test/appScenarios.renders.tsx.
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
Expand Down Expand Up @@ -65,3 +70,80 @@ it("a new draft does not load its reserved thread before the server lists it", a
// A 404 here marks the thread deleted, and the view never loads it once the send creates it.
expect(threadRequests).toEqual([]);
});

async function openThreadDetailsOnNonRepository(thread: FakeThread) {
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,
}),
),
);
return stack.move();
} catch (error) {
await stack.disposeAsync();
throw error;
}
}

it("offers Initialize Git for a project folder that is not a repository", async () => {
await using _app = await openThreadDetailsOnNonRepository(threadWithHistory("root-thread", 1));

await expect.element(page.getByRole("button", { name: "Initialize Git" })).toBeVisible();
});

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",
});
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();
});
23 changes: 21 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 @@ -90,7 +91,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 { serverEnvironment } from "~/state/server";
Expand Down Expand Up @@ -965,6 +967,17 @@ 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 !== true;
const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
Expand Down Expand Up @@ -1484,7 +1497,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
34 changes: 29 additions & 5 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 OrchestrationV2ProjectedTurnItem,
Expand Down Expand Up @@ -187,6 +188,7 @@ export interface FakeThread {
readonly sideChatOf?: ThreadId;
readonly id: ThreadId;
readonly title: string;
readonly worktreePath?: string;
readonly runs: ReadonlyArray<OrchestrationV2Run>;
readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
}
Expand Down Expand Up @@ -225,7 +227,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 @@ -320,6 +322,7 @@ let terminalMetadataSubscriber: Queue.Queue<TerminalMetadataStreamEvent> | null
let threads: ReadonlyArray<FakeThread> = [];
let sequence = 1;
let vcsStatusSubscriber: Queue.Queue<VcsStatusStreamEvent> | null = null;
let serverConfigSubscriber: Queue.Queue<ServerConfigStreamEvent> | null = null;

const subscribe = <A>(first: ReadonlyArray<A>, register?: (queue: Queue.Queue<A>) => () => void) =>
Stream.unwrap(
Expand All @@ -334,6 +337,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> = {
[WS_METHODS.subscribeTerminalMetadata]: () =>
subscribe<TerminalMetadataStreamEvent>([{ type: "snapshot", terminals: [] }], (queue) => {
Expand Down Expand Up @@ -381,8 +395,7 @@ const handlers: Record<string, (input: never) => unknown> = {
if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null;
};
}),
[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 @@ -398,8 +411,7 @@ const client = new Proxy(handlers, {
const session: EnvironmentConnectionLease["session"] = {
client: client as unknown as WsRpcProtocolClient,
initialConfig: Effect.succeed(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 @@ -499,6 +511,18 @@ export function runningRun(threadId: ThreadId, id: string, ordinal: number, user

export { userItem };

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

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));
Expand Down
Loading