authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 17:30:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 17:30:14-07:00
log1024941575067f68d909c6b98f56b02edac25b37
treeae42968137d607ac441e4cf6e6bf430e64c22aff
parentfc4ecc835e6d54eb411c333d737f2c4881884338
signature Signed by PGP key B5690EEEBB952194

feat(server,web): choose where new worktrees are created (#16231)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

16 files changed, 320 insertions(+), 11 deletions(-)

apps/server/src/environment/ServerEnvironment.ts+1
...@@ -229,6 +229,7 @@ export const make = Effect.gen(function* () {...@@ -229,6 +229,7 @@ export const make = Effect.gen(function* () {
229 threadAutoSettlement: true,229 threadAutoSettlement: true,
230 storageCleanup: true,230 storageCleanup: true,
231 projectWorktreeCleanup: true,231 projectWorktreeCleanup: true,
232 worktreesDirectory: true,
232 threadRestartContinuation: true,233 threadRestartContinuation: true,
233 projectSettingsOverrides: true,234 projectSettingsOverrides: true,
234 threadSnooze: true,235 threadSnooze: true,
apps/server/src/git/GitManager.ts+8-1
...@@ -733,6 +733,11 @@ export const make = Effect.gen(function* () {...@@ -733,6 +733,11 @@ export const make = Effect.gen(function* () {
733 );733 );
734 return resolveProjectSettings(settings, projectId).settings;734 return resolveProjectSettings(settings, projectId).settings;
735 });735 });
736 // Best effort: a settings read failure falls back to the default location.
737 const readWorktreesDirectory = serverSettingsService.getSettings.pipe(
738 Effect.map((settings) => settings.worktreesDirectory),
739 Effect.orElseSucceed(() => ""),
740 );
736 const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn(741 const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn(
737 "GitManager.createWorktree",742 "GitManager.createWorktree",
738 )(function* (input, options) {743 )(function* (input, options) {
...@@ -743,7 +748,8 @@ export const make = Effect.gen(function* () {...@@ -743,7 +748,8 @@ export const make = Effect.gen(function* () {
743 Effect.map((settings) => settings.worktreeSubmodules),748 Effect.map((settings) => settings.worktreeSubmodules),
744 Effect.orElseSucceed(() => null),749 Effect.orElseSucceed(() => null),
745 );750 );
746 return yield* gitCore.createWorktree(input, { ...options, submodules });751 const worktreesDirectory = yield* readWorktreesDirectory;
752 return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules });
747 });753 });
748754
749 const readRepositoryInstructions = (cwd: string, fileName: string) =>755 const readRepositoryInstructions = (cwd: string, fileName: string) =>
...@@ -2604,6 +2610,7 @@ export const make = Effect.gen(function* () {...@@ -2604,6 +2610,7 @@ export const make = Effect.gen(function* () {
2604 path: null,2610 path: null,
2605 },2611 },
2606 {2612 {
2613 worktreesDirectory: yield* readWorktreesDirectory,
2607 // Best effort: a settings read failure falls back to the checkout's t3.json.2614 // Best effort: a settings read failure falls back to the checkout's t3.json.
2608 submodules: yield* projectSettingsFor(input).pipe(2615 submodules: yield* projectSettingsFor(input).pipe(
2609 Effect.map((settings) => settings.worktreeSubmodules),2616 Effect.map((settings) => settings.worktreeSubmodules),
apps/server/src/review/ReviewService.test.ts+44
...@@ -6,6 +6,7 @@ import * as Layer from "effect/Layer";...@@ -6,6 +6,7 @@ import * as Layer from "effect/Layer";
6import * as PlatformError from "effect/PlatformError";6import * as PlatformError from "effect/PlatformError";
77
8import * as ServerConfig from "../config.ts";8import * as ServerConfig from "../config.ts";
9import * as ServerSettings from "../serverSettings.ts";
9import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";10import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
10import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";11import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
11import * as ReviewService from "./ReviewService.ts";12import * as ReviewService from "./ReviewService.ts";
...@@ -14,6 +15,8 @@ function makeLayer(input: {...@@ -14,6 +15,8 @@ function makeLayer(input: {
14 readonly workspaceRoot: string;15 readonly workspaceRoot: string;
15 readonly baseDir: string;16 readonly baseDir: string;
16 readonly detectCalls?: Array<{ readonly cwd: string }>;17 readonly detectCalls?: Array<{ readonly cwd: string }>;
18 readonly worktreesDirectory?: string;
19 readonly previousWorktreesDirectories?: ReadonlyArray<string>;
17}) {20}) {
18 return ReviewService.layer.pipe(21 return ReviewService.layer.pipe(
19 Layer.provide(22 Layer.provide(
...@@ -28,6 +31,12 @@ function makeLayer(input: {...@@ -28,6 +31,12 @@ function makeLayer(input: {
28 }),31 }),
29 ),32 ),
30 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),33 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
34 Layer.provide(
35 ServerSettings.ServerSettingsService.layerTest({
36 worktreesDirectory: input.worktreesDirectory ?? "",
37 previousWorktreesDirectories: [...(input.previousWorktreesDirectories ?? [])],
38 }),
39 ),
31 Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)),40 Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)),
32 Layer.provideMerge(NodeServices.layer),41 Layer.provideMerge(NodeServices.layer),
33 );42 );
...@@ -90,6 +99,41 @@ describe("ReviewService", () => {...@@ -90,6 +99,41 @@ describe("ReviewService", () => {
90 }).pipe(Effect.provide(NodeServices.layer)),99 }).pipe(Effect.provide(NodeServices.layer)),
91 );100 );
92101
102 it.effect("allows previous custom worktree locations but never a filesystem root", () =>
103 Effect.gen(function* () {
104 const fs = yield* FileSystem.FileSystem;
105 const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
106 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
107 const previous = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-old-worktrees-" });
108 const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" });
109
110 const result = yield* Effect.gen(function* () {
111 const review = yield* ReviewService.ReviewService;
112 return yield* review.getDiffPreview({ cwd: previous });
113 }).pipe(
114 Effect.provide(
115 makeLayer({
116 workspaceRoot,
117 baseDir,
118 worktreesDirectory: "/",
119 previousWorktreesDirectories: [previous],
120 }),
121 ),
122 );
123 assert.strictEqual(result.cwd, previous);
124
125 const rootLink = `${baseDir}/root-link`;
126 yield* fs.symlink("/", rootLink);
127 for (const worktreesDirectory of ["/", rootLink]) {
128 const error = yield* Effect.gen(function* () {
129 const review = yield* ReviewService.ReviewService;
130 return yield* review.getDiffPreview({ cwd: outsideRoot }).pipe(Effect.flip);
131 }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, worktreesDirectory })));
132 assert.strictEqual(error._tag, "VcsRepositoryDetectionError");
133 }
134 }).pipe(Effect.provide(NodeServices.layer)),
135 );
136
93 it.effect("allows diff preview cwd inside the configured workspace root", () =>137 it.effect("allows diff preview cwd inside the configured workspace root", () =>
94 Effect.gen(function* () {138 Effect.gen(function* () {
95 const fs = yield* FileSystem.FileSystem;139 const fs = yield* FileSystem.FileSystem;
apps/server/src/review/ReviewService.ts+21-3
...@@ -18,6 +18,8 @@ import {...@@ -18,6 +18,8 @@ import {
18import * as ServerConfig from "../config.ts";18import * as ServerConfig from "../config.ts";
19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
20import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";20import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
21import * as ServerSettings from "../serverSettings.ts";
22import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts";
2123
22export class ReviewService extends Context.Service<24export class ReviewService extends Context.Service<
23 ReviewService,25 ReviewService,
...@@ -38,6 +40,7 @@ export const make = Effect.gen(function* () {...@@ -38,6 +40,7 @@ export const make = Effect.gen(function* () {
38 const path = yield* Path.Path;40 const path = yield* Path.Path;
39 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;41 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
40 const git = yield* GitVcsDriver.GitVcsDriver;42 const git = yield* GitVcsDriver.GitVcsDriver;
43 const settings = yield* ServerSettings.ServerSettingsService;
4144
42 const canonicalizePath = (value: string) => {45 const canonicalizePath = (value: string) => {
43 const resolvedPath = path.resolve(value);46 const resolvedPath = path.resolve(value);
...@@ -67,13 +70,28 @@ export const make = Effect.gen(function* () {...@@ -67,13 +70,28 @@ export const make = Effect.gen(function* () {
67 operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",70 operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
68 cwd: string,71 cwd: string,
69 ) {72 ) {
70 const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([73 const worktreesDirectories = yield* settings.getSettings.pipe(
74 Effect.orElseSucceed(() => ({ worktreesDirectory: "", previousWorktreesDirectories: [] })),
75 );
76 const [candidate, workspaceRoot, worktreesRoots] = yield* Effect.all([
71 canonicalizePath(cwd),77 canonicalizePath(cwd),
72 canonicalizePath(config.cwd),78 canonicalizePath(config.cwd),
73 canonicalizePath(config.worktreesDir),79 // A managed root that cannot be resolved, or resolves to a filesystem
80 // root through a symlink, is skipped rather than failing every review.
81 Effect.forEach(
82 managedWorktreesDirectories(worktreesDirectories, config.worktreesDir, path),
83 (directory) => canonicalizePath(directory).pipe(Effect.orElseSucceed(() => null)),
84 ).pipe(
85 Effect.map((roots) =>
86 roots.filter((root): root is string => root !== null && !isFilesystemRoot(root, path)),
87 ),
88 ),
74 ]);89 ]);
7590
76 if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) {91 if (
92 isWithinRoot(candidate, workspaceRoot) ||
93 worktreesRoots.some((root) => isWithinRoot(candidate, root))
94 ) {
77 return;95 return;
78 }96 }
7997
apps/server/src/storageCleanup.ts+22-4
...@@ -35,6 +35,7 @@ import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementSer...@@ -35,6 +35,7 @@ import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementSer
35import { forkParked } from "./serverActivation.ts";35import { forkParked } from "./serverActivation.ts";
36import * as Settings from "./serverSettings.ts";36import * as Settings from "./serverSettings.ts";
37import * as TerminalManager from "./terminal/Manager.ts";37import * as TerminalManager from "./terminal/Manager.ts";
38import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts";
38import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";39import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
39import { withWorkspaceLease } from "./workspace/workspaceLease.ts";40import { withWorkspaceLease } from "./workspace/workspaceLease.ts";
4041
...@@ -176,7 +177,20 @@ export const make = Effect.gen(function* () {...@@ -176,7 +177,20 @@ export const make = Effect.gen(function* () {
176 now: number,177 now: number,
177 ) {178 ) {
178 if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return;179 if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return;
179 if (!(yield* fs.exists(config.worktreesDir))) return;180 const roots: Array<string> = [];
181 for (const directory of managedWorktreesDirectories(
182 serverSettings,
183 config.worktreesDir,
184 path,
185 )) {
186 // An unmounted drive only skips its own worktrees.
187 const root = yield* fs.exists(directory).pipe(
188 Effect.flatMap((exists) => (exists ? fs.realPath(directory) : Effect.succeed(null))),
189 Effect.orElseSucceed(() => null),
190 );
191 if (root !== null && !isFilesystemRoot(root, path)) roots.push(root);
192 }
193 if (roots.length === 0) return;
180 const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete);194 const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete);
181 const deletedRows = hasDeleteRule195 const deletedRows = hasDeleteRule
182 ? yield* sql<{ payload_json: string; workspaceRoot: string }>`196 ? yield* sql<{ payload_json: string; workspaceRoot: string }>`
...@@ -197,7 +211,6 @@ export const make = Effect.gen(function* () {...@@ -197,7 +211,6 @@ export const make = Effect.gen(function* () {
197 resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete,211 resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete,
198 );212 );
199 const snapshot = yield* readThreads();213 const snapshot = yield* readThreads();
200 const root = yield* fs.realPath(config.worktreesDir);
201 const refreshedDefaultRefs = new Map<string, Set<string>>();214 const refreshedDefaultRefs = new Map<string, Set<string>>();
202 const groups = Map.groupBy(215 const groups = Map.groupBy(
203 snapshot.threads.filter((thread) => thread.worktreePath !== null),216 snapshot.threads.filter((thread) => thread.worktreePath !== null),
...@@ -222,8 +235,13 @@ export const make = Effect.gen(function* () {...@@ -222,8 +235,13 @@ export const make = Effect.gen(function* () {
222 )235 )
223 continue;236 continue;
224 yield* Effect.gen(function* () {237 yield* Effect.gen(function* () {
225 if (!inside(root, worktreePath) || !(yield* fs.exists(worktreePath))) return;238 if (!(yield* fs.exists(worktreePath))) return;
226 if ((yield* fs.realPath(worktreePath)) !== worktreePath) return;239 // Roots are canonical, so compare canonical paths. A symlinked parent
240 // (a linked drive) is fine; a symlinked worktree directory is not.
241 const realPath = yield* fs.realPath(worktreePath);
242 const realParent = yield* fs.realPath(path.dirname(worktreePath));
243 if (realPath !== path.join(realParent, path.basename(worktreePath))) return;
244 if (!roots.some((root) => inside(root, realPath))) return;
227 if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return;245 if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return;
228 // A linked worktree has a .git file. Never remove a main checkout.246 // A linked worktree has a .git file. Never remove a main checkout.
229 if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return;247 if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return;
apps/server/src/vcs/GitVcsDriver.ts+2
...@@ -156,6 +156,8 @@ export interface CreateWorktreeOptions {...@@ -156,6 +156,8 @@ export interface CreateWorktreeOptions {
156 * own t3.json.156 * own t3.json.
157 */157 */
158 readonly submodules?: WorktreeSubmodules | null;158 readonly submodules?: WorktreeSubmodules | null;
159 /** The `worktreesDirectory` setting, used when the input has no explicit path. */
160 readonly worktreesDirectory?: string;
159}161}
160162
161export interface GitCommitProgress {163export interface GitCommitProgress {
apps/server/src/vcs/GitVcsDriverCore.test.ts+39
...@@ -2981,6 +2981,45 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -2981,6 +2981,45 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
2981 }),2981 }),
2982 );2982 );
29832983
2984 it.effect("creates worktrees under the configured worktrees directory", () =>
2985 Effect.gen(function* () {
2986 const fileSystem = yield* FileSystem.FileSystem;
2987 const pathService = yield* Path.Path;
2988 const cwd = yield* makeTmpDir();
2989 const { initialBranch } = yield* initRepoWithCommit(cwd);
2990 const worktreesDirectory = yield* makeTmpDir("custom-worktrees-");
2991 const driver = yield* GitVcsDriver.GitVcsDriver;
2992
2993 const created = yield* driver.createWorktree(
2994 { cwd, path: null, refName: initialBranch, newRefName: "feature/custom-dir" },
2995 { worktreesDirectory },
2996 );
2997 const expected = pathService.join(
2998 worktreesDirectory,
2999 pathService.basename(cwd),
3000 "feature-custom-dir",
3001 );
3002 assert.equal(created.worktree.path, expected);
3003 assert.equal(yield* fileSystem.exists(expected), true);
3004
3005 const error = yield* driver
3006 .createWorktree(
3007 { cwd, path: null, refName: initialBranch, newRefName: "feature/relative-dir" },
3008 { worktreesDirectory: "relative/worktrees" },
3009 )
3010 .pipe(Effect.flip);
3011 assert.match(error.detail, /must be an absolute folder on this machine/);
3012
3013 const rootError = yield* driver
3014 .createWorktree(
3015 { cwd, path: null, refName: initialBranch, newRefName: "feature/root-dir" },
3016 { worktreesDirectory: "/" },
3017 )
3018 .pipe(Effect.flip);
3019 assert.match(rootError.detail, /not a drive root/);
3020 }),
3021 );
3022
2984 it.effect("resolves the submodule mode from the option, then t3.json", () =>3023 it.effect("resolves the submodule mode from the option, then t3.json", () =>
2985 Effect.gen(function* () {3024 Effect.gen(function* () {
2986 const fileSystem = yield* FileSystem.FileSystem;3025 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/vcs/GitVcsDriverCore.ts+18-1
...@@ -36,6 +36,7 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";...@@ -36,6 +36,7 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
36import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";36import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
37import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";37import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
38import * as GitVcsDriver from "./GitVcsDriver.ts";38import * as GitVcsDriver from "./GitVcsDriver.ts";
39import { resolveWorktreesDirectory } from "../worktreesDirectory.ts";
39import {40import {
40 parseRemoteNames,41 parseRemoteNames,
41 parseRemoteNamesInGitOrder,42 parseRemoteNamesInGitOrder,
...@@ -3345,7 +3346,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3345,7 +3346,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3345 const targetBranch = input.newRefName ?? input.refName;3346 const targetBranch = input.newRefName ?? input.refName;
3346 const sanitizedBranch = targetBranch.replace(/\//g, "-");3347 const sanitizedBranch = targetBranch.replace(/\//g, "-");
3347 const repoName = path.basename(input.cwd);3348 const repoName = path.basename(input.cwd);
3348 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);3349 let worktreePath = input.path;
3350 if (worktreePath == null) {
3351 const parentDir = resolveWorktreesDirectory(
3352 options?.worktreesDirectory ?? "",
3353 worktreesDir,
3354 path,
3355 );
3356 if (parentDir === null) {
3357 return yield* new GitCommandError({
3358 operation: "GitVcsDriver.createWorktree",
3359 command: "git worktree add",
3360 cwd: input.cwd,
3361 detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings → Storage.`,
3362 });
3363 }
3364 worktreePath = path.join(parentDir, repoName, sanitizedBranch);
3365 }
3349 const args = input.newRefName3366 const args = input.newRefName
3350 ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]3367 ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
3351 : ["worktree", "add", worktreePath, input.refName];3368 : ["worktree", "add", worktreePath, input.refName];
apps/server/src/worktreesDirectory.ts created+44
...@@ -0,0 +1,44 @@
1import type * as Path from "effect/Path";
2
3import { expandHomePathWith } from "./pathExpansion.ts";
4
5/**
6 * Directory new worktrees are created under: the `worktreesDirectory`
7 * setting, or `defaultDir` (`<T3 home>/worktrees`) when it is empty. Null when
8 * the setting is not an absolute path on this machine, such as `D:\worktrees`
9 * configured for a Windows server and synced to a Linux one, or when it is a
10 * filesystem root, which would make every path on that drive look managed.
11 */
12export function resolveWorktreesDirectory(
13 setting: string,
14 defaultDir: string,
15 path: Path.Path,
16): string | null {
17 if (setting === "") return defaultDir;
18 const expanded = expandHomePathWith(setting, path);
19 if (!path.isAbsolute(expanded)) return null;
20 const resolved = path.resolve(expanded);
21 return isFilesystemRoot(resolved, path) ? null : resolved;
22}
23
24/** Callers re-check after resolving symlinks: a link can point at a root. */
25export function isFilesystemRoot(directory: string, path: Path.Path): boolean {
26 return path.dirname(directory) === directory;
27}
28
29/** Every directory that holds T3-managed worktrees on this machine. */
30export function managedWorktreesDirectories(
31 settings: {
32 readonly worktreesDirectory: string;
33 readonly previousWorktreesDirectories: ReadonlyArray<string>;
34 },
35 defaultDir: string,
36 path: Path.Path,
37): ReadonlyArray<string> {
38 const directories = new Set([defaultDir]);
39 for (const setting of [settings.worktreesDirectory, ...settings.previousWorktreesDirectories]) {
40 const directory = resolveWorktreesDirectory(setting, defaultDir, path);
41 if (directory !== null) directories.add(directory);
42 }
43 return [...directories];
44}
apps/web/src/components/settings/StorageSettings.tsx+65-2
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts";1import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts";
2import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";2import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
3import { useState } from "react";3import { useRef, useState } from "react";
44
5import { Input } from "../ui/input";
5import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";6import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
6import { Switch } from "../ui/switch";7import { Switch } from "../ui/switch";
7import {8import {
...@@ -11,16 +12,77 @@ import {...@@ -11,16 +12,77 @@ import {
11 NumberFieldIncrement,12 NumberFieldIncrement,
12 NumberFieldInput,13 NumberFieldInput,
13} from "../ui/number-field";14} from "../ui/number-field";
14import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";15import {
16 SettingResetButton,
17 SettingsPageContainer,
18 SettingsRow,
19 SettingsSection,
20} from "./settingsLayout";
15import { SettingsScopeNotice } from "./SettingsScopeNotice";21import { SettingsScopeNotice } from "./SettingsScopeNotice";
16import type { ScopedSettingsTarget } from "./scopedSettings";22import type { ScopedSettingsTarget } from "./scopedSettings";
17import { useSettingsScope } from "./SettingsScopeContext";23import { useSettingsScope } from "./SettingsScopeContext";
24import { searchableSetting } from "./settingsSearch";
18import {25import {
19 useClearScopedSettings,26 useClearScopedSettings,
20 useScopedSettings,27 useScopedSettings,
28 useScopedSettingsMixed,
21 useUpdateScopedSettings,29 useUpdateScopedSettings,
22} from "./useScopedSettings";30} from "./useScopedSettings";
2331
32function WorktreesDirectoryRow() {
33 const { connectedEnvironments, targets } = useSettingsScope();
34 const settings = useScopedSettings();
35 const updateSettings = useUpdateScopedSettings();
36 const mixed = useScopedSettingsMixed(["worktreesDirectory"]);
37 const edited = useRef(false);
38 if (
39 connectedEnvironments.some(
40 (environment) =>
41 environment.serverConfig?.environment.capabilities.worktreesDirectory !== true,
42 )
43 )
44 return null;
45 const scopeKey = targets.map((target) => target.environmentId).join(",");
46
47 return (
48 <SettingsRow
49 {...searchableSetting("storage-worktrees-location")}
50 description={
51 "Folder where new worktrees are created, on any drive, such as D:\\worktrees or ~/worktrees. Existing worktrees stay where they are. Leave empty to use the T3 home folder."
52 }
53 serverScoped
54 settingKeys={["worktreesDirectory"]}
55 resetAction={
56 mixed || settings.worktreesDirectory !== "" ? (
57 <SettingResetButton
58 label="worktree location"
59 onClick={() => updateSettings({ worktreesDirectory: "" })}
60 />
61 ) : null
62 }
63 control={
64 <Input
65 key={`${scopeKey}:${mixed}:${settings.worktreesDirectory}`}
66 aria-label="Worktree location"
67 autoCapitalize="none"
68 spellCheck={false}
69 placeholder={mixed ? "Mixed" : "Default"}
70 defaultValue={mixed ? "" : settings.worktreesDirectory}
71 onChange={() => {
72 edited.current = true;
73 }}
74 onBlur={(event) => {
75 const value = event.target.value.trim();
76 if (edited.current && (mixed || value !== settings.worktreesDirectory))
77 updateSettings({ worktreesDirectory: value });
78 edited.current = false;
79 }}
80 />
81 }
82 />
83 );
84}
85
24function RetentionControl({86function RetentionControl({
25 label,87 label,
26 value,88 value,
...@@ -152,6 +214,7 @@ export function StorageSettingsPanel() {...@@ -152,6 +214,7 @@ export function StorageSettingsPanel() {
152 return (214 return (
153 <SettingsPageContainer>215 <SettingsPageContainer>
154 <SettingsSection id="storage-worktrees" title="Worktrees">216 <SettingsSection id="storage-worktrees" title="Worktrees">
217 {!isProjectScope && <WorktreesDirectoryRow />}
155 {isProjectScope && (218 {isProjectScope && (
156 <SettingsRow219 <SettingsRow
157 title="Automatic worktree cleanup"220 title="Automatic worktree cleanup"
apps/web/src/components/settings/settingsSearch.ts+7
...@@ -143,6 +143,13 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -143,6 +143,13 @@ export const SETTINGS_SEARCH_ITEMS = [
143 "disk storage delete deleted archived threads old inactive merged unchanged worktrees retention days project inherit off custom",143 "disk storage delete deleted archived threads old inactive merged unchanged worktrees retention days project inherit off custom",
144 ],144 ],
145 },145 },
146 {
147 id: "storage-worktrees-location",
148 title: "Worktree location",
149 to: "/settings/storage",
150 scope: "environment-defaults",
151 searchTerms: ["worktree location folder directory path drive external disk"],
152 },
146 {153 {
147 id: "storage-artifacts",154 id: "storage-artifacts",
148 title: "Artifacts and logs",155 title: "Artifacts and logs",
docs/user/project-settings.md+7
...@@ -123,6 +123,13 @@ itself, or **Skip** to leave them for a setup script. It resolves in the same or...@@ -123,6 +123,13 @@ itself, or **Skip** to leave them for a setup script. It resolves in the same or
123workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out123workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out
124applies when the project and environment are both on **Inherit**.124applies when the project and environment are both on **Inherit**.
125125
126## Worktree location
127
128New worktrees go in the `worktrees` folder of the T3 home directory. To put them somewhere else,
129such as another drive, set **Settings → Storage → Worktree location** to an absolute path like
130`D:\worktrees` or `~/worktrees`. The setting is per machine. Existing worktrees stay where they
131are, and cleanup covers both the default folder and the custom one.
132
126## Storage cleanup133## Storage cleanup
127134
128Open **Settings → Storage** to enable automatic cleanup on one machine or all connected135Open **Settings → Storage** to enable automatic cleanup on one machine or all connected
packages/contracts/src/environment.ts+2
...@@ -126,6 +126,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -126,6 +126,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
126 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),126 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
127 storageCleanup: Schema.optionalKey(Schema.Boolean),127 storageCleanup: Schema.optionalKey(Schema.Boolean),
128 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),128 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
129 /** Server honors the `worktreesDirectory` setting. */
130 worktreesDirectory: Schema.optionalKey(Schema.Boolean),
129 /** Server persists the opt-in for continuing interrupted threads after restarts. */131 /** Server persists the opt-in for continuing interrupted threads after restarts. */
130 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),132 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
131 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */133 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
packages/contracts/src/settings.ts+13
...@@ -1217,6 +1217,18 @@ export const ServerSettings = Schema.Struct({...@@ -1217,6 +1217,18 @@ export const ServerSettings = Schema.Struct({
1217 storageCleanup: StorageCleanupSettings.pipe(1217 storageCleanup: StorageCleanupSettings.pipe(
1218 Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))),1218 Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))),
1219 ),1219 ),
1220 /**
1221 * Absolute directory new worktrees are created under, e.g. `D:\worktrees`
1222 * or `~/worktrees`. Empty uses `<T3 home>/worktrees`.
1223 */
1224 worktreesDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
1225 /**
1226 * Custom locations used before the current one. Server-maintained so
1227 * worktrees left there stay eligible for cleanup and review diffs.
1228 */
1229 previousWorktreesDirectories: Schema.Array(TrimmedString).pipe(
1230 Schema.withDecodingDefault(Effect.succeed([])),
1231 ),
1220 responseStreamingMode: ResponseStreamingMode.pipe(1232 responseStreamingMode: ResponseStreamingMode.pipe(
1221 Schema.withDecodingDefault(Effect.succeed("paragraph" as const)),1233 Schema.withDecodingDefault(Effect.succeed("paragraph" as const)),
1222 ),1234 ),
...@@ -1620,6 +1632,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1620,6 +1632,7 @@ export const ServerSettingsPatch = Schema.Struct({
1620 logsAfterDays: Schema.optionalKey(StorageRetentionDays),1632 logsAfterDays: Schema.optionalKey(StorageRetentionDays),
1621 }),1633 }),
1622 ),1634 ),
1635 worktreesDirectory: Schema.optionalKey(TrimmedString),
1623 // Server settings1636 // Server settings
1624 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),1637 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
1625 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),1638 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
packages/shared/src/serverSettings.test.ts+13
...@@ -795,3 +795,16 @@ describe("serverSettings helpers", () => {...@@ -795,3 +795,16 @@ describe("serverSettings helpers", () => {
795 expect(resolved.pauseWhenOnBattery).toBe(false);795 expect(resolved.pauseWhenOnBattery).toBe(false);
796 });796 });
797});797});
798
799describe("worktreesDirectory", () => {
800 it("remembers previous custom locations so their worktrees stay managed", () => {
801 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { worktreesDirectory: "/a" });
802 expect(first.previousWorktreesDirectories).toEqual([]);
803 const second = applyServerSettingsPatch(first, { worktreesDirectory: "/b" });
804 expect(second.previousWorktreesDirectories).toEqual(["/a"]);
805 const reset = applyServerSettingsPatch(second, { worktreesDirectory: "" });
806 expect(reset.previousWorktreesDirectories).toEqual(["/a", "/b"]);
807 const back = applyServerSettingsPatch(reset, { worktreesDirectory: "/a" });
808 expect(back.previousWorktreesDirectories).toEqual(["/b"]);
809 });
810});
packages/shared/src/serverSettings.ts+14
...@@ -364,6 +364,20 @@ export function applyServerSettingsPatch(...@@ -364,6 +364,20 @@ export function applyServerSettingsPatch(
364 ...(patch.providerInstances !== undefined364 ...(patch.providerInstances !== undefined
365 ? { providerInstances: patch.providerInstances }365 ? { providerInstances: patch.providerInstances }
366 : {}),366 : {}),
367 ...(patch.worktreesDirectory !== undefined &&
368 patch.worktreesDirectory !== current.worktreesDirectory
369 ? {
370 previousWorktreesDirectories: [
371 ...current.previousWorktreesDirectories.filter(
372 (directory) => directory !== patch.worktreesDirectory,
373 ),
374 ...(current.worktreesDirectory !== "" &&
375 !current.previousWorktreesDirectories.includes(current.worktreesDirectory)
376 ? [current.worktreesDirectory]
377 : []),
378 ],
379 }
380 : {}),
367 ...(projectSettingsOverridesPatch !== undefined381 ...(projectSettingsOverridesPatch !== undefined
368 ? {382 ? {
369 projectSettingsOverrides: Object.fromEntries(383 projectSettingsOverrides: Object.fromEntries(