authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 13:25:00-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 13:25:00-03:00
logcfbd1457ad093b1cd7f6c53851009a62b676b203
treeacfa0da7038618ab544cc4aa7b1e4fb47bbe549a
parenta11f464133291122e0f6381b35e1b245d0aa9c73
signature Signed by PGP key B5690EEEBB952194

fix(server): show diffs for projects outside the server cwd (#17724)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

5 files changed, 77 insertions(+), 22 deletions(-)

apps/server/src/git/GitManager.ts+4-1
......@@ -765,7 +765,10 @@ export const make = Effect.gen(function* () {
765765 Effect.gen(function* () {
766766 const root = yield* fileSystem.realPath(cwd);
767767 const instructionPath = yield* fileSystem.realPath(path.join(root, fileName));
768 if (!instructionPath.startsWith(`${root}${path.sep}`)) {
768 // A drive root such as `D:\` already ends with a separator, so compare
769 // with path.relative instead of a `${root}${sep}` prefix.
770 const relative = path.relative(root, instructionPath);
771 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
769772 return "";
770773 }
771774 const info = yield* fileSystem.stat(instructionPath);
apps/server/src/review/ReviewService.test.ts+56
......@@ -4,8 +4,10 @@ import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
66import * as PlatformError from "effect/PlatformError";
7import { ProjectId } from "@t3tools/contracts";
78
89import * as ServerConfig from "../config.ts";
10import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
911import * as ServerSettings from "../serverSettings.ts";
1012import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1113import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
......@@ -17,8 +19,30 @@ function layer(input: {
1719 readonly detectCalls?: Array<{ readonly cwd: string }>;
1820 readonly worktreesDirectory?: string;
1921 readonly previousWorktreesDirectories?: ReadonlyArray<string>;
22 readonly projectRoots?: ReadonlyArray<string> | "unavailable";
2023}) {
24 const projectRoots = input.projectRoots ?? [];
2125 return ReviewService.layer.pipe(
26 Layer.provide(
27 Layer.mock(ProjectStore.ProjectStoreV2)({
28 listShells: () =>
29 projectRoots === "unavailable"
30 ? Effect.fail(
31 new ProjectStore.ProjectStoreV2Error({ operation: "list", cause: "offline" }),
32 )
33 : Effect.succeed(
34 projectRoots.map((workspaceRoot, index) => ({
35 id: ProjectId.make(`project-${index}`),
36 title: `Project ${index}`,
37 workspaceRoot,
38 defaultModelSelection: null,
39 scripts: [],
40 createdAt: "2026-01-01T00:00:00.000Z",
41 updatedAt: "2026-01-01T00:00:00.000Z",
42 })),
43 ),
44 }),
45 ),
2246 Layer.provide(
2347 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
2448 get: () => Effect.die("unexpected VCS registry get"),
......@@ -134,6 +158,38 @@ describe("ReviewService", () => {
134158 }).pipe(Effect.provide(NodeServices.layer)),
135159 );
136160
161 it.effect("allows registered project roots outside the server cwd", () =>
162 Effect.gen(function* () {
163 const fs = yield* FileSystem.FileSystem;
164 const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
165 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
166 // Stands in for a project on another drive than the server's home cwd.
167 const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-project-" });
168 const projectChild = `${projectRoot}/packages/app`;
169 yield* fs.makeDirectory(projectChild, { recursive: true });
170 const sibling = `${projectRoot}-sibling`;
171 yield* fs.makeDirectory(sibling);
172 yield* Effect.addFinalizer(() => fs.remove(sibling, { recursive: true }).pipe(Effect.ignore));
173
174 const review = (cwd: string, projectRoots: ReadonlyArray<string> | "unavailable") =>
175 Effect.gen(function* () {
176 const service = yield* ReviewService.ReviewService;
177 return yield* service.getDiffPreview({ cwd });
178 }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, projectRoots })));
179
180 assert.strictEqual((yield* review(projectRoot, [projectRoot])).cwd, projectRoot);
181 assert.strictEqual((yield* review(projectChild, [projectRoot])).cwd, projectChild);
182 for (const [cwd, projectRoots] of [
183 [sibling, [projectRoot]],
184 [projectRoot, []],
185 [projectRoot, "unavailable"],
186 ] as const) {
187 const error = yield* review(cwd, projectRoots).pipe(Effect.flip);
188 assert.strictEqual(error._tag, "VcsRepositoryDetectionError");
189 }
190 }).pipe(Effect.provide(NodeServices.layer)),
191 );
192
137193 it.effect("allows diff preview cwd inside the configured workspace root", () =>
138194 Effect.gen(function* () {
139195 const fs = yield* FileSystem.FileSystem;
apps/server/src/review/ReviewService.ts+15
......@@ -16,6 +16,7 @@ import {
1616} from "@t3tools/contracts";
1717
1818import * as ServerConfig from "../config.ts";
19import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
1920import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2021import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
2122import * as ServerSettings from "../serverSettings.ts";
......@@ -42,6 +43,7 @@ export const make = Effect.gen(function* () {
4243 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
4344 const git = yield* GitVcsDriver.GitVcsDriver;
4445 const settings = yield* ServerSettings.ServerSettingsService;
46 const projectStore = yield* ProjectStore.ProjectStoreV2;
4547
4648 const canonicalizePath = (value: string) => {
4749 const resolvedPath = path.resolve(value);
......@@ -97,6 +99,19 @@ export const make = Effect.gen(function* () {
9799 return;
98100 }
99101
102 // Registered projects can live outside the server cwd, which is the home
103 // directory in packaged desktop builds, e.g. a repository on another
104 // Windows drive. Unreadable or unresolvable project roots grant nothing.
105 const projects = yield* projectStore.listShells().pipe(Effect.orElseSucceed(() => []));
106 for (const project of projects) {
107 const root = yield* canonicalizePath(project.workspaceRoot).pipe(
108 Effect.orElseSucceed(() => null),
109 );
110 if (root !== null && isWithinRoot(candidate, root)) {
111 return;
112 }
113 }
114
100115 return yield* new VcsRepositoryDetectionError({
101116 operation,
102117 cwd,
apps/server/src/server.ts+1
......@@ -374,6 +374,7 @@ const layerProjectCloneTracker = ProjectCloneTracker.layer.pipe(
374374);
375375
376376const layerReview = ReviewService.layer.pipe(
377 Layer.provide(ProjectStore.layer),
377378 Layer.provideMerge(GitVcsDriver.layer),
378379 Layer.provideMerge(layerVcsDriverRegistry),
379380);
apps/web/src/components/DiffPanel.tsx+1-21
......@@ -339,7 +339,7 @@ export default function DiffPanel({
339339 },
340340 { enabled: isGitRepo && selectedTurn !== undefined },
341341 );
342 const primaryBranchDiffPreview = useEnvironmentQuery(
342 const branchDiffPreview = useEnvironmentQuery(
343343 canReadFiles && selectedRunId === null && activeThread && activeCwd
344344 ? reviewEnvironment.diffPreview({
345345 environmentId: activeThread.environmentId,
......@@ -351,26 +351,6 @@ export default function DiffPanel({
351351 })
352352 : null,
353353 );
354 const shouldRetryBranchDiffAtEnvironmentCwd =
355 selectedRunId === null &&
356 primaryBranchDiffPreview.error?.includes("configured workspace root") === true &&
357 serverConfig?.cwd !== undefined &&
358 serverConfig.cwd !== activeCwd;
359 const fallbackBranchDiffPreview = useEnvironmentQuery(
360 canReadFiles && shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig
361 ? reviewEnvironment.diffPreview({
362 environmentId: activeThread.environmentId,
363 input: {
364 cwd: serverConfig.cwd,
365 ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}),
366 ignoreWhitespace: diffIgnoreWhitespace,
367 },
368 })
369 : null,
370 );
371 const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd
372 ? fallbackBranchDiffPreview
373 : primaryBranchDiffPreview;
374354 const canRefreshGitDiff =
375355 isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null;
376356 const activeThreadRefreshKey = routeThreadRef