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* () {
229229 threadAutoSettlement: true,
230230 storageCleanup: true,
231231 projectWorktreeCleanup: true,
232 worktreesDirectory: true,
232233 threadRestartContinuation: true,
233234 projectSettingsOverrides: true,
234235 threadSnooze: true,
apps/server/src/git/GitManager.ts+8-1
......@@ -733,6 +733,11 @@ export const make = Effect.gen(function* () {
733733 );
734734 return resolveProjectSettings(settings, projectId).settings;
735735 });
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 );
736741 const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn(
737742 "GitManager.createWorktree",
738743 )(function* (input, options) {
......@@ -743,7 +748,8 @@ export const make = Effect.gen(function* () {
743748 Effect.map((settings) => settings.worktreeSubmodules),
744749 Effect.orElseSucceed(() => null),
745750 );
746 return yield* gitCore.createWorktree(input, { ...options, submodules });
751 const worktreesDirectory = yield* readWorktreesDirectory;
752 return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules });
747753 });
748754
749755 const readRepositoryInstructions = (cwd: string, fileName: string) =>
......@@ -2604,6 +2610,7 @@ export const make = Effect.gen(function* () {
26042610 path: null,
26052611 },
26062612 {
2613 worktreesDirectory: yield* readWorktreesDirectory,
26072614 // Best effort: a settings read failure falls back to the checkout's t3.json.
26082615 submodules: yield* projectSettingsFor(input).pipe(
26092616 Effect.map((settings) => settings.worktreeSubmodules),
apps/server/src/review/ReviewService.test.ts+44
......@@ -6,6 +6,7 @@ import * as Layer from "effect/Layer";
66import * as PlatformError from "effect/PlatformError";
77
88import * as ServerConfig from "../config.ts";
9import * as ServerSettings from "../serverSettings.ts";
910import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1011import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1112import * as ReviewService from "./ReviewService.ts";
......@@ -14,6 +15,8 @@ function makeLayer(input: {
1415 readonly workspaceRoot: string;
1516 readonly baseDir: string;
1617 readonly detectCalls?: Array<{ readonly cwd: string }>;
18 readonly worktreesDirectory?: string;
19 readonly previousWorktreesDirectories?: ReadonlyArray<string>;
1720}) {
1821 return ReviewService.layer.pipe(
1922 Layer.provide(
......@@ -28,6 +31,12 @@ function makeLayer(input: {
2831 }),
2932 ),
3033 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
34 Layer.provide(
35 ServerSettings.ServerSettingsService.layerTest({
36 worktreesDirectory: input.worktreesDirectory ?? "",
37 previousWorktreesDirectories: [...(input.previousWorktreesDirectories ?? [])],
38 }),
39 ),
3140 Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)),
3241 Layer.provideMerge(NodeServices.layer),
3342 );
......@@ -90,6 +99,41 @@ describe("ReviewService", () => {
9099 }).pipe(Effect.provide(NodeServices.layer)),
91100 );
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
93137 it.effect("allows diff preview cwd inside the configured workspace root", () =>
94138 Effect.gen(function* () {
95139 const fs = yield* FileSystem.FileSystem;
apps/server/src/review/ReviewService.ts+21-3
......@@ -18,6 +18,8 @@ import {
1818import * as ServerConfig from "../config.ts";
1919import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2020import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
21import * as ServerSettings from "../serverSettings.ts";
22import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts";
2123
2224export class ReviewService extends Context.Service<
2325 ReviewService,
......@@ -38,6 +40,7 @@ export const make = Effect.gen(function* () {
3840 const path = yield* Path.Path;
3941 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
4042 const git = yield* GitVcsDriver.GitVcsDriver;
43 const settings = yield* ServerSettings.ServerSettingsService;
4144
4245 const canonicalizePath = (value: string) => {
4346 const resolvedPath = path.resolve(value);
......@@ -67,13 +70,28 @@ export const make = Effect.gen(function* () {
6770 operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
6871 cwd: string,
6972 ) {
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([
7177 canonicalizePath(cwd),
7278 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 ),
7489 ]);
7590
76 if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) {
91 if (
92 isWithinRoot(candidate, workspaceRoot) ||
93 worktreesRoots.some((root) => isWithinRoot(candidate, root))
94 ) {
7795 return;
7896 }
7997
apps/server/src/storageCleanup.ts+22-4
......@@ -35,6 +35,7 @@ import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementSer
3535import { forkParked } from "./serverActivation.ts";
3636import * as Settings from "./serverSettings.ts";
3737import * as TerminalManager from "./terminal/Manager.ts";
38import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts";
3839import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
3940import { withWorkspaceLease } from "./workspace/workspaceLease.ts";
4041
......@@ -176,7 +177,20 @@ export const make = Effect.gen(function* () {
176177 now: number,
177178 ) {
178179 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;
180194 const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete);
181195 const deletedRows = hasDeleteRule
182196 ? yield* sql<{ payload_json: string; workspaceRoot: string }>`
......@@ -197,7 +211,6 @@ export const make = Effect.gen(function* () {
197211 resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete,
198212 );
199213 const snapshot = yield* readThreads();
200 const root = yield* fs.realPath(config.worktreesDir);
201214 const refreshedDefaultRefs = new Map<string, Set<string>>();
202215 const groups = Map.groupBy(
203216 snapshot.threads.filter((thread) => thread.worktreePath !== null),
......@@ -222,8 +235,13 @@ export const make = Effect.gen(function* () {
222235 )
223236 continue;
224237 yield* Effect.gen(function* () {
225 if (!inside(root, worktreePath) || !(yield* fs.exists(worktreePath))) return;
226 if ((yield* fs.realPath(worktreePath)) !== worktreePath) return;
238 if (!(yield* fs.exists(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;
227245 if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return;
228246 // A linked worktree has a .git file. Never remove a main checkout.
229247 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 {
156156 * own t3.json.
157157 */
158158 readonly submodules?: WorktreeSubmodules | null;
159 /** The `worktreesDirectory` setting, used when the input has no explicit path. */
160 readonly worktreesDirectory?: string;
159161}
160162
161163export interface GitCommitProgress {
apps/server/src/vcs/GitVcsDriverCore.test.ts+39
......@@ -2981,6 +2981,45 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
29812981 }),
29822982 );
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
29843023 it.effect("resolves the submodule mode from the option, then t3.json", () =>
29853024 Effect.gen(function* () {
29863025 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/vcs/GitVcsDriverCore.ts+18-1
......@@ -36,6 +36,7 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
3636import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
3737import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
3838import * as GitVcsDriver from "./GitVcsDriver.ts";
39import { resolveWorktreesDirectory } from "../worktreesDirectory.ts";
3940import {
4041 parseRemoteNames,
4142 parseRemoteNamesInGitOrder,
......@@ -3345,7 +3346,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
33453346 const targetBranch = input.newRefName ?? input.refName;
33463347 const sanitizedBranch = targetBranch.replace(/\//g, "-");
33473348 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 }
33493366 const args = input.newRefName
33503367 ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
33513368 : ["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 @@
11import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts";
22import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
3import { useState } from "react";
3import { useRef, useState } from "react";
44
5import { Input } from "../ui/input";
56import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
67import { Switch } from "../ui/switch";
78import {
......@@ -11,16 +12,77 @@ import {
1112 NumberFieldIncrement,
1213 NumberFieldInput,
1314} from "../ui/number-field";
14import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
15import {
16 SettingResetButton,
17 SettingsPageContainer,
18 SettingsRow,
19 SettingsSection,
20} from "./settingsLayout";
1521import { SettingsScopeNotice } from "./SettingsScopeNotice";
1622import type { ScopedSettingsTarget } from "./scopedSettings";
1723import { useSettingsScope } from "./SettingsScopeContext";
24import { searchableSetting } from "./settingsSearch";
1825import {
1926 useClearScopedSettings,
2027 useScopedSettings,
28 useScopedSettingsMixed,
2129 useUpdateScopedSettings,
2230} 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
2486function RetentionControl({
2587 label,
2688 value,
......@@ -152,6 +214,7 @@ export function StorageSettingsPanel() {
152214 return (
153215 <SettingsPageContainer>
154216 <SettingsSection id="storage-worktrees" title="Worktrees">
217 {!isProjectScope && <WorktreesDirectoryRow />}
155218 {isProjectScope && (
156219 <SettingsRow
157220 title="Automatic worktree cleanup"
apps/web/src/components/settings/settingsSearch.ts+7
......@@ -143,6 +143,13 @@ export const SETTINGS_SEARCH_ITEMS = [
143143 "disk storage delete deleted archived threads old inactive merged unchanged worktrees retention days project inherit off custom",
144144 ],
145145 },
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 },
146153 {
147154 id: "storage-artifacts",
148155 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
123123workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out
124124applies 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
126133## Storage cleanup
127134
128135Open **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({
126126 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
127127 storageCleanup: Schema.optionalKey(Schema.Boolean),
128128 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
129 /** Server honors the `worktreesDirectory` setting. */
130 worktreesDirectory: Schema.optionalKey(Schema.Boolean),
129131 /** Server persists the opt-in for continuing interrupted threads after restarts. */
130132 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
131133 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
packages/contracts/src/settings.ts+13
......@@ -1217,6 +1217,18 @@ export const ServerSettings = Schema.Struct({
12171217 storageCleanup: StorageCleanupSettings.pipe(
12181218 Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))),
12191219 ),
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 ),
12201232 responseStreamingMode: ResponseStreamingMode.pipe(
12211233 Schema.withDecodingDefault(Effect.succeed("paragraph" as const)),
12221234 ),
......@@ -1620,6 +1632,7 @@ export const ServerSettingsPatch = Schema.Struct({
16201632 logsAfterDays: Schema.optionalKey(StorageRetentionDays),
16211633 }),
16221634 ),
1635 worktreesDirectory: Schema.optionalKey(TrimmedString),
16231636 // Server settings
16241637 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
16251638 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
packages/shared/src/serverSettings.test.ts+13
......@@ -795,3 +795,16 @@ describe("serverSettings helpers", () => {
795795 expect(resolved.pauseWhenOnBattery).toBe(false);
796796 });
797797});
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(
364364 ...(patch.providerInstances !== undefined
365365 ? { providerInstances: patch.providerInstances }
366366 : {}),
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 : {}),
367381 ...(projectSettingsOverridesPatch !== undefined
368382 ? {
369383 projectSettingsOverrides: Object.fromEntries(