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* () {...@@ -765,7 +765,10 @@ export const make = Effect.gen(function* () {
765 Effect.gen(function* () {765 Effect.gen(function* () {
766 const root = yield* fileSystem.realPath(cwd);766 const root = yield* fileSystem.realPath(cwd);
767 const instructionPath = yield* fileSystem.realPath(path.join(root, fileName));767 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)) {
769 return "";772 return "";
770 }773 }
771 const info = yield* fileSystem.stat(instructionPath);774 const info = yield* fileSystem.stat(instructionPath);
apps/server/src/review/ReviewService.test.ts+56
...@@ -4,8 +4,10 @@ import * as Effect from "effect/Effect";...@@ -4,8 +4,10 @@ import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as PlatformError from "effect/PlatformError";6import * as PlatformError from "effect/PlatformError";
7import { ProjectId } from "@t3tools/contracts";
78
8import * as ServerConfig from "../config.ts";9import * as ServerConfig from "../config.ts";
10import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
9import * as ServerSettings from "../serverSettings.ts";11import * as ServerSettings from "../serverSettings.ts";
10import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";12import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
11import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";13import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
...@@ -17,8 +19,30 @@ function layer(input: {...@@ -17,8 +19,30 @@ function layer(input: {
17 readonly detectCalls?: Array<{ readonly cwd: string }>;19 readonly detectCalls?: Array<{ readonly cwd: string }>;
18 readonly worktreesDirectory?: string;20 readonly worktreesDirectory?: string;
19 readonly previousWorktreesDirectories?: ReadonlyArray<string>;21 readonly previousWorktreesDirectories?: ReadonlyArray<string>;
22 readonly projectRoots?: ReadonlyArray<string> | "unavailable";
20}) {23}) {
24 const projectRoots = input.projectRoots ?? [];
21 return ReviewService.layer.pipe(25 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 ),
22 Layer.provide(46 Layer.provide(
23 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({47 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
24 get: () => Effect.die("unexpected VCS registry get"),48 get: () => Effect.die("unexpected VCS registry get"),
...@@ -134,6 +158,38 @@ describe("ReviewService", () => {...@@ -134,6 +158,38 @@ describe("ReviewService", () => {
134 }).pipe(Effect.provide(NodeServices.layer)),158 }).pipe(Effect.provide(NodeServices.layer)),
135 );159 );
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
137 it.effect("allows diff preview cwd inside the configured workspace root", () =>193 it.effect("allows diff preview cwd inside the configured workspace root", () =>
138 Effect.gen(function* () {194 Effect.gen(function* () {
139 const fs = yield* FileSystem.FileSystem;195 const fs = yield* FileSystem.FileSystem;
apps/server/src/review/ReviewService.ts+15
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
1717
18import * as ServerConfig from "../config.ts";18import * as ServerConfig from "../config.ts";
19import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";20import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
20import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";21import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
21import * as ServerSettings from "../serverSettings.ts";22import * as ServerSettings from "../serverSettings.ts";
...@@ -42,6 +43,7 @@ export const make = Effect.gen(function* () {...@@ -42,6 +43,7 @@ export const make = Effect.gen(function* () {
42 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;43 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
43 const git = yield* GitVcsDriver.GitVcsDriver;44 const git = yield* GitVcsDriver.GitVcsDriver;
44 const settings = yield* ServerSettings.ServerSettingsService;45 const settings = yield* ServerSettings.ServerSettingsService;
46 const projectStore = yield* ProjectStore.ProjectStoreV2;
4547
46 const canonicalizePath = (value: string) => {48 const canonicalizePath = (value: string) => {
47 const resolvedPath = path.resolve(value);49 const resolvedPath = path.resolve(value);
...@@ -97,6 +99,19 @@ export const make = Effect.gen(function* () {...@@ -97,6 +99,19 @@ export const make = Effect.gen(function* () {
97 return;99 return;
98 }100 }
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
100 return yield* new VcsRepositoryDetectionError({115 return yield* new VcsRepositoryDetectionError({
101 operation,116 operation,
102 cwd,117 cwd,
apps/server/src/server.ts+1
...@@ -374,6 +374,7 @@ const layerProjectCloneTracker = ProjectCloneTracker.layer.pipe(...@@ -374,6 +374,7 @@ const layerProjectCloneTracker = ProjectCloneTracker.layer.pipe(
374);374);
375375
376const layerReview = ReviewService.layer.pipe(376const layerReview = ReviewService.layer.pipe(
377 Layer.provide(ProjectStore.layer),
377 Layer.provideMerge(GitVcsDriver.layer),378 Layer.provideMerge(GitVcsDriver.layer),
378 Layer.provideMerge(layerVcsDriverRegistry),379 Layer.provideMerge(layerVcsDriverRegistry),
379);380);
apps/web/src/components/DiffPanel.tsx+1-21
...@@ -339,7 +339,7 @@ export default function DiffPanel({...@@ -339,7 +339,7 @@ export default function DiffPanel({
339 },339 },
340 { enabled: isGitRepo && selectedTurn !== undefined },340 { enabled: isGitRepo && selectedTurn !== undefined },
341 );341 );
342 const primaryBranchDiffPreview = useEnvironmentQuery(342 const branchDiffPreview = useEnvironmentQuery(
343 canReadFiles && selectedRunId === null && activeThread && activeCwd343 canReadFiles && selectedRunId === null && activeThread && activeCwd
344 ? reviewEnvironment.diffPreview({344 ? reviewEnvironment.diffPreview({
345 environmentId: activeThread.environmentId,345 environmentId: activeThread.environmentId,
...@@ -351,26 +351,6 @@ export default function DiffPanel({...@@ -351,26 +351,6 @@ export default function DiffPanel({
351 })351 })
352 : null,352 : null,
353 );353 );
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;
374 const canRefreshGitDiff =354 const canRefreshGitDiff =
375 isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null;355 isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null;
376 const activeThreadRefreshKey = routeThreadRef356 const activeThreadRefreshKey = routeThreadRef