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 @@...@@ -1,6 +1,12 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * 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";
4import * as Deferred from "effect/Deferred";10import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";12import * as Fiber from "effect/Fiber";
...@@ -8,9 +14,11 @@ import * as FileSystem from "effect/FileSystem";...@@ -8,9 +14,11 @@ import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";15import * as Option from "effect/Option";
10import * as Path from "effect/Path";16import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";
11import * as Scope from "effect/Scope";18import * as Scope from "effect/Scope";
1219
13import * as ServerConfig from "../config.ts";20import * as ServerConfig from "../config.ts";
21import * as GitManager from "../git/GitManager.ts";
14import * as GitWorkflow from "../git/GitWorkflowService.ts";22import * as GitWorkflow from "../git/GitWorkflowService.ts";
15import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";23import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
16import * as SqlitePersistence from "../persistence/Sqlite.ts";24import * as SqlitePersistence from "../persistence/Sqlite.ts";
...@@ -25,20 +33,13 @@ import * as ProjectService from "./ProjectService.ts";...@@ -25,20 +33,13 @@ import * as ProjectService from "./ProjectService.ts";
25import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";33import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
26import * as ManagedProjectFolders from "./ManagedProjectFolders.ts";34import * as ManagedProjectFolders from "./ManagedProjectFolders.ts";
2735
28// Real repository detection: the service only asks the Git workflow whether36// The real Git workflow over real repository detection, with a mock GitManager
29// the data dir is inside a checkout.37// since the service only asks whether the data dir is inside a checkout.
30const layerGitWorkflow = Layer.unwrap(38const layerGitWorkflow = (vcsProcess?: Layer.Layer<VcsProcess.VcsProcess>) =>
31 Effect.gen(function* () {39 GitWorkflow.layer.pipe(
32 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;40 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(vcsProcess ?? VcsProcess.layer))),
33 return Layer.mock(GitWorkflow.GitWorkflowService)({41 Layer.provide(Layer.mock(GitManager.GitManager)({})),
34 isRepository: (cwd) =>42 );
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))));
4243
43const layerEnrichment = ProjectEnrichmentService.layer.pipe(44const layerEnrichment = ProjectEnrichmentService.layer.pipe(
44 Layer.provide(45 Layer.provide(
...@@ -56,6 +57,8 @@ const layerEnrichment = ProjectEnrichmentService.layer.pipe(...@@ -56,6 +57,8 @@ const layerEnrichment = ProjectEnrichmentService.layer.pipe(
56const layerRealGit = GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer));57const layerRealGit = GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer));
5758
58interface HarnessOptions {59interface HarnessOptions {
60 /** The processes repository detection runs, such as a machine with no Git. */
61 readonly vcsProcess?: Layer.Layer<VcsProcess.VcsProcess>;
59 /** A git driver for failures real git cannot produce on demand. */62 /** A git driver for failures real git cannot produce on demand. */
60 readonly git?: Layer.Layer<GitVcsDriver.GitVcsDriver>;63 readonly git?: Layer.Layer<GitVcsDriver.GitVcsDriver>;
61 /** Wraps the real ProjectService, for failures it cannot produce on demand. */64 /** Wraps the real ProjectService, for failures it cannot produce on demand. */
...@@ -81,7 +84,7 @@ const layer = (baseDir: string, options?: HarnessOptions) =>...@@ -81,7 +84,7 @@ const layer = (baseDir: string, options?: HarnessOptions) =>
81 Layer.provideMerge(RuntimeLayer.layerProjectService),84 Layer.provideMerge(RuntimeLayer.layerProjectService),
82 Layer.provideMerge(layerEnrichment),85 Layer.provideMerge(layerEnrichment),
83 Layer.provideMerge(WorkspacePaths.layer),86 Layer.provideMerge(WorkspacePaths.layer),
84 Layer.provideMerge(layerGitWorkflow),87 Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)),
85 Layer.provideMerge(options?.git ?? layerRealGit),88 Layer.provideMerge(options?.git ?? layerRealGit),
86 Layer.provideMerge(SqlitePersistence.layerMemory),89 Layer.provideMerge(SqlitePersistence.layerMemory),
87 Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),90 Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),
...@@ -145,6 +148,34 @@ it.effect("offers nothing when the data dir sits inside a Git checkout", () =>...@@ -145,6 +148,34 @@ it.effect("offers nothing when the data dir sits inside a Git checkout", () =>
145 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),148 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
146);149);
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
148it.effect("creates one Scratch project, even for concurrent first requests", () =>179it.effect("creates one Scratch project, even for concurrent first requests", () =>
149 withScratch(() =>180 withScratch(() =>
150 Effect.gen(function* () {181 Effect.gen(function* () {
apps/server/src/project/ManagedProjectFolders.ts+19-2
...@@ -9,7 +9,13 @@...@@ -9,7 +9,13 @@
9 *9 *
10 * @module ManagedProjectFolders10 * @module ManagedProjectFolders
11 */11 */
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";
13import { newProjectFolderName } from "@t3tools/shared/path";19import { newProjectFolderName } from "@t3tools/shared/path";
14import * as Cause from "effect/Cause";20import * as Cause from "effect/Cause";
15import * as Context from "effect/Context";21import * as Context from "effect/Context";
...@@ -220,6 +226,15 @@ function describeCommitFailure(stderr: string): string {...@@ -220,6 +226,15 @@ function describeCommitFailure(stderr: string): string {
220 return lines.at(-1) ?? "Git could not make the first commit.";226 return lines.at(-1) ?? "Git could not make the first commit.";
221}227}
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
223const make = Effect.gen(function* () {238const make = Effect.gen(function* () {
224 const config = yield* ServerConfig.ServerConfig;239 const config = yield* ServerConfig.ServerConfig;
225 const fileSystem = yield* FileSystem.FileSystem;240 const fileSystem = yield* FileSystem.FileSystem;
...@@ -258,11 +273,13 @@ const make = Effect.gen(function* () {...@@ -258,11 +273,13 @@ const make = Effect.gen(function* () {
258273
259 // Inside a checkout (a dev worktree's .t3, a dotfiles home) the folder would274 // Inside a checkout (a dev worktree's .t3, a dotfiles home) the folder would
260 // inherit the repo's git status and checkpoints, so Scratch is offered only275 // 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; detection276 // 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
262 // failures hide Scratch rather than failing callers. An interrupted probe278 // failures hide Scratch rather than failing callers. An interrupted probe
263 // invalidates the cache so the next caller probes again.279 // invalidates the cache so the next caller probes again.
264 const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL(280 const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL(
265 gitWorkflow.isRepository(config.baseDir).pipe(281 gitWorkflow.isRepository(config.baseDir).pipe(
282 Effect.catchIf(isGitMissing, () => Effect.succeed(false)),
266 Effect.map((isRepository) =>283 Effect.map((isRepository) =>
267 isRepository ? Option.none<string>() : Option.some(path.resolve(config.baseDir, "scratch")),284 isRepository ? Option.none<string>() : Option.some(path.resolve(config.baseDir, "scratch")),
268 ),285 ),