diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8ca0b6b976a9..63a8d0d8bac5 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -764,7 +764,10 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); - if (!instructionPath.startsWith(`${root}${path.sep}`)) { + // A drive root such as `D:\` already ends with a separator, so compare + // with path.relative instead of a `${root}${sep}` prefix. + const relative = path.relative(root, instructionPath); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { return ""; } const info = yield* fileSystem.stat(instructionPath); diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 0802bac2d73a..384b8ad40d12 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -4,8 +4,10 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import { ProjectId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -17,8 +19,30 @@ function layer(input: { readonly detectCalls?: Array<{ readonly cwd: string }>; readonly worktreesDirectory?: string; readonly previousWorktreesDirectories?: ReadonlyArray; + readonly projectRoots?: ReadonlyArray | "unavailable"; }) { + const projectRoots = input.projectRoots ?? []; return ReviewService.layer.pipe( + Layer.provide( + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + projectRoots === "unavailable" + ? Effect.fail( + new ProjectStore.ProjectStoreV2Error({ operation: "list", cause: "offline" }), + ) + : Effect.succeed( + projectRoots.map((workspaceRoot, index) => ({ + id: ProjectId.make(`project-${index}`), + title: `Project ${index}`, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + })), + ), + }), + ), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: () => Effect.die("unexpected VCS registry get"), @@ -134,6 +158,38 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("allows registered project roots outside the server cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + // Stands in for a project on another drive than the server's home cwd. + const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-project-" }); + const projectChild = `${projectRoot}/packages/app`; + yield* fs.makeDirectory(projectChild, { recursive: true }); + const sibling = `${projectRoot}-sibling`; + yield* fs.makeDirectory(sibling); + yield* Effect.addFinalizer(() => fs.remove(sibling, { recursive: true }).pipe(Effect.ignore)); + + const review = (cwd: string, projectRoots: ReadonlyArray | "unavailable") => + Effect.gen(function* () { + const service = yield* ReviewService.ReviewService; + return yield* service.getDiffPreview({ cwd }); + }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, projectRoots }))); + + assert.strictEqual((yield* review(projectRoot, [projectRoot])).cwd, projectRoot); + assert.strictEqual((yield* review(projectChild, [projectRoot])).cwd, projectChild); + for (const [cwd, projectRoots] of [ + [sibling, [projectRoot]], + [projectRoot, []], + [projectRoot, "unavailable"], + ] as const) { + const error = yield* review(cwd, projectRoots).pipe(Effect.flip); + assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("allows diff preview cwd inside the configured workspace root", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index d8e27c9d6dd7..1bde38b589b4 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -42,6 +43,7 @@ export const make = Effect.gen(function* () { const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const settings = yield* ServerSettings.ServerSettingsService; + const projectStore = yield* ProjectStore.ProjectStoreV2; const canonicalizePath = (value: string) => { const resolvedPath = path.resolve(value); @@ -97,6 +99,19 @@ export const make = Effect.gen(function* () { return; } + // Registered projects can live outside the server cwd, which is the home + // directory in packaged desktop builds, e.g. a repository on another + // Windows drive. Unreadable or unresolvable project roots grant nothing. + const projects = yield* projectStore.listShells().pipe(Effect.orElseSucceed(() => [])); + for (const project of projects) { + const root = yield* canonicalizePath(project.workspaceRoot).pipe( + Effect.orElseSucceed(() => null), + ); + if (root !== null && isWithinRoot(candidate, root)) { + return; + } + } + return yield* new VcsRepositoryDetectionError({ operation, cwd, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98a541409380..0707410e065e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -373,6 +373,7 @@ const layerProjectCloneTracker = ProjectCloneTracker.layer.pipe( ); const layerReview = ReviewService.layer.pipe( + Layer.provide(ProjectStore.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(layerVcsDriverRegistry), ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 633094738e9e..0c7a4a28988d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -339,7 +339,7 @@ export default function DiffPanel({ }, { enabled: isGitRepo && selectedTurn !== undefined }, ); - const primaryBranchDiffPreview = useEnvironmentQuery( + const branchDiffPreview = useEnvironmentQuery( canReadFiles && selectedRunId === null && activeThread && activeCwd ? reviewEnvironment.diffPreview({ environmentId: activeThread.environmentId, @@ -351,26 +351,6 @@ export default function DiffPanel({ }) : null, ); - const shouldRetryBranchDiffAtEnvironmentCwd = - selectedRunId === null && - primaryBranchDiffPreview.error?.includes("configured workspace root") === true && - serverConfig?.cwd !== undefined && - serverConfig.cwd !== activeCwd; - const fallbackBranchDiffPreview = useEnvironmentQuery( - canReadFiles && shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig - ? reviewEnvironment.diffPreview({ - environmentId: activeThread.environmentId, - input: { - cwd: serverConfig.cwd, - ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), - ignoreWhitespace: diffIgnoreWhitespace, - }, - }) - : null, - ); - const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd - ? fallbackBranchDiffPreview - : primaryBranchDiffPreview; const canRefreshGitDiff = isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null; const activeThreadRefreshKey = routeThreadRef