authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-10 17:54:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 17:54:59-07:00
logc73b45480fb79575a90794bfd1d45d538aacb108
tree44bdc557aa533ebebe50b02f9002612f50e6f24e
parent0ecb25a8d45b7768bada974a9d6a99b17cf8bf49
signature Signed by PGP key B5690EEEBB952194

fix(server): threads without a project no longer need Git installed (#17959)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 66 insertions(+), 18 deletions(-)

apps/server/src/project/ManagedProjectFolders.test.ts+47-16
......@@ -1,6 +1,12 @@
11import { assert, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
3import { CommandId, GitCommandError, ProjectId, ThreadId } from "@t3tools/contracts";
3import {
4 CommandId,
5 GitCommandError,
6 ProjectId,
7 ThreadId,
8 VcsProcessSpawnError,
9} from "@t3tools/contracts";
410import * as Deferred from "effect/Deferred";
511import * as Effect from "effect/Effect";
612import * as Fiber from "effect/Fiber";
......@@ -8,9 +14,11 @@ import * as FileSystem from "effect/FileSystem";
814import * as Layer from "effect/Layer";
915import * as Option from "effect/Option";
1016import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";
1118import * as Scope from "effect/Scope";
1219
1320import * as ServerConfig from "../config.ts";
21import * as GitManager from "../git/GitManager.ts";
1422import * as GitWorkflow from "../git/GitWorkflowService.ts";
1523import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
1624import * as SqlitePersistence from "../persistence/Sqlite.ts";
......@@ -25,20 +33,13 @@ import * as ProjectService from "./ProjectService.ts";
2533import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
2634import * as ManagedProjectFolders from "./ManagedProjectFolders.ts";
2735
28// Real repository detection: the service only asks the Git workflow whether
29// the data dir is inside a checkout.
30const layerGitWorkflow = Layer.unwrap(
31 Effect.gen(function* () {
32 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
33 return Layer.mock(GitWorkflow.GitWorkflowService)({
34 isRepository: (cwd) =>
35 registry.detect({ cwd }).pipe(
36 Effect.map((handle) => handle?.kind === "git"),
37 Effect.orDie,
38 ),
39 });
40 }),
41).pipe(Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))));
36// The real Git workflow over real repository detection, with a mock GitManager
37// since the service only asks whether the data dir is inside a checkout.
38const layerGitWorkflow = (vcsProcess?: Layer.Layer<VcsProcess.VcsProcess>) =>
39 GitWorkflow.layer.pipe(
40 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(vcsProcess ?? VcsProcess.layer))),
41 Layer.provide(Layer.mock(GitManager.GitManager)({})),
42 );
4243
4344const layerEnrichment = ProjectEnrichmentService.layer.pipe(
4445 Layer.provide(
......@@ -56,6 +57,8 @@ const layerEnrichment = ProjectEnrichmentService.layer.pipe(
5657const layerRealGit = GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer));
5758
5859interface HarnessOptions {
60 /** The processes repository detection runs, such as a machine with no Git. */
61 readonly vcsProcess?: Layer.Layer<VcsProcess.VcsProcess>;
5962 /** A git driver for failures real git cannot produce on demand. */
6063 readonly git?: Layer.Layer<GitVcsDriver.GitVcsDriver>;
6164 /** Wraps the real ProjectService, for failures it cannot produce on demand. */
......@@ -81,7 +84,7 @@ const layer = (baseDir: string, options?: HarnessOptions) =>
8184 Layer.provideMerge(RuntimeLayer.layerProjectService),
8285 Layer.provideMerge(layerEnrichment),
8386 Layer.provideMerge(WorkspacePaths.layer),
84 Layer.provideMerge(layerGitWorkflow),
87 Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)),
8588 Layer.provideMerge(options?.git ?? layerRealGit),
8689 Layer.provideMerge(SqlitePersistence.layerMemory),
8790 Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),
......@@ -145,6 +148,34 @@ it.effect("offers nothing when the data dir sits inside a Git checkout", () =>
145148 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
146149);
147150
151it.effect("offers a Scratch folder when Git is not installed", () =>
152 withScratch(
153 ({ baseDir }) =>
154 Effect.gen(function* () {
155 const path = yield* Path.Path;
156 assert.equal(yield* requireRoot, path.resolve(baseDir, "scratch"));
157 }),
158 {
159 vcsProcess: Layer.succeed(VcsProcess.VcsProcess, {
160 run: (input) =>
161 Effect.fail(
162 new VcsProcessSpawnError({
163 operation: input.operation,
164 command: input.command,
165 cwd: input.cwd,
166 cause: PlatformError.systemError({
167 _tag: "NotFound",
168 module: "ChildProcess",
169 method: "spawn",
170 syscall: `spawn ${input.command}`,
171 }),
172 }),
173 ),
174 }),
175 },
176 ),
177);
178
148179it.effect("creates one Scratch project, even for concurrent first requests", () =>
149180 withScratch(() =>
150181 Effect.gen(function* () {
apps/server/src/project/ManagedProjectFolders.ts+19-2
......@@ -9,7 +9,13 @@
99 *
1010 * @module ManagedProjectFolders
1111 */
12import { CommandId, ProjectId, type ThreadId } from "@t3tools/contracts";
12import {
13 CommandId,
14 type GitManagerServiceError,
15 ProjectId,
16 type ThreadId,
17 VcsProcessSpawnError,
18} from "@t3tools/contracts";
1319import { newProjectFolderName } from "@t3tools/shared/path";
1420import * as Cause from "effect/Cause";
1521import * as Context from "effect/Context";
......@@ -220,6 +226,15 @@ function describeCommitFailure(stderr: string): string {
220226 return lines.at(-1) ?? "Git could not make the first commit.";
221227}
222228
229const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError);
230
231// Spawning a `git` that is not on PATH fails with NotFound, on Windows too.
232const isGitMissing = (error: GitManagerServiceError) =>
233 error._tag === "GitManagerError" &&
234 isVcsProcessSpawnError(error.cause) &&
235 PlatformError.isPlatformError(error.cause.cause) &&
236 error.cause.cause.reason._tag === "NotFound";
237
223238const make = Effect.gen(function* () {
224239 const config = yield* ServerConfig.ServerConfig;
225240 const fileSystem = yield* FileSystem.FileSystem;
......@@ -258,11 +273,13 @@ const make = Effect.gen(function* () {
258273
259274 // Inside a checkout (a dev worktree's .t3, a dotfiles home) the folder would
260275 // inherit the repo's git status and checkpoints, so Scratch is offered only
261 // when the data dir is outside any work tree. Probed once; detection
276 // when the data dir is outside any work tree. With no Git installed nothing
277 // can see a checkout, so Scratch is offered. Probed once; other detection
262278 // failures hide Scratch rather than failing callers. An interrupted probe
263279 // invalidates the cache so the next caller probes again.
264280 const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL(
265281 gitWorkflow.isRepository(config.baseDir).pipe(
282 Effect.catchIf(isGitMissing, () => Effect.succeed(false)),
266283 Effect.map((isRepository) =>
267284 isRepository ? Option.none<string>() : Option.some(path.resolve(config.baseDir, "scratch")),
268285 ),