| author | |
| committer | |
| log | 1024941575067f68d909c6b98f56b02edac25b37 |
| tree | ae42968137d607ac441e4cf6e6bf430e64c22aff |
| parent | fc4ecc835e6d54eb411c333d737f2c4881884338 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 229 | threadAutoSettlement: true, |
| 230 | 230 | storageCleanup: true, |
| 231 | 231 | projectWorktreeCleanup: true, |
| 232 | worktreesDirectory: true, | |
| 232 | 233 | threadRestartContinuation: true, |
| 233 | 234 | projectSettingsOverrides: true, |
| 234 | 235 | threadSnooze: true, |
apps/server/src/git/GitManager.ts+8-1| ... | ... | @@ -733,6 +733,11 @@ export const make = Effect.gen(function* () { |
| 733 | 733 | ); |
| 734 | 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 | 741 | const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn( |
| 737 | 742 | "GitManager.createWorktree", |
| 738 | 743 | )(function* (input, options) { |
| ... | ... | @@ -743,7 +748,8 @@ export const make = Effect.gen(function* () { |
| 743 | 748 | Effect.map((settings) => settings.worktreeSubmodules), |
| 744 | 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 | }); |
| 748 | 754 | |
| 749 | 755 | const readRepositoryInstructions = (cwd: string, fileName: string) => |
| ... | ... | @@ -2604,6 +2610,7 @@ export const make = Effect.gen(function* () { |
| 2604 | 2610 | path: null, |
| 2605 | 2611 | }, |
| 2606 | 2612 | { |
| 2613 | worktreesDirectory: yield* readWorktreesDirectory, | |
| 2607 | 2614 | // Best effort: a settings read failure falls back to the checkout's t3.json. |
| 2608 | 2615 | submodules: yield* projectSettingsFor(input).pipe( |
| 2609 | 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 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 7 | |
| 8 | 8 | import * as ServerConfig from "../config.ts"; |
| 9 | import * as ServerSettings from "../serverSettings.ts"; | |
| 9 | 10 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 10 | 11 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 11 | 12 | import * as ReviewService from "./ReviewService.ts"; |
| ... | ... | @@ -14,6 +15,8 @@ function makeLayer(input: { |
| 14 | 15 | readonly workspaceRoot: string; |
| 15 | 16 | readonly baseDir: string; |
| 16 | 17 | readonly detectCalls?: Array<{ readonly cwd: string }>; |
| 18 | readonly worktreesDirectory?: string; | |
| 19 | readonly previousWorktreesDirectories?: ReadonlyArray<string>; | |
| 17 | 20 | }) { |
| 18 | 21 | return ReviewService.layer.pipe( |
| 19 | 22 | Layer.provide( |
| ... | ... | @@ -28,6 +31,12 @@ function makeLayer(input: { |
| 28 | 31 | }), |
| 29 | 32 | ), |
| 30 | 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 | 40 | Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), |
| 32 | 41 | Layer.provideMerge(NodeServices.layer), |
| 33 | 42 | ); |
| ... | ... | @@ -90,6 +99,41 @@ describe("ReviewService", () => { |
| 90 | 99 | }).pipe(Effect.provide(NodeServices.layer)), |
| 91 | 100 | ); |
| 92 | 101 | |
| 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 | 137 | it.effect("allows diff preview cwd inside the configured workspace root", () => |
| 94 | 138 | Effect.gen(function* () { |
| 95 | 139 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/review/ReviewService.ts+21-3| ... | ... | @@ -18,6 +18,8 @@ import { |
| 18 | 18 | import * as ServerConfig from "../config.ts"; |
| 19 | 19 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 20 | 20 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 21 | import * as ServerSettings from "../serverSettings.ts"; | |
| 22 | import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts"; | |
| 21 | 23 | |
| 22 | 24 | export class ReviewService extends Context.Service< |
| 23 | 25 | ReviewService, |
| ... | ... | @@ -38,6 +40,7 @@ export const make = Effect.gen(function* () { |
| 38 | 40 | const path = yield* Path.Path; |
| 39 | 41 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; |
| 40 | 42 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 43 | const settings = yield* ServerSettings.ServerSettingsService; | |
| 41 | 44 | |
| 42 | 45 | const canonicalizePath = (value: string) => { |
| 43 | 46 | const resolvedPath = path.resolve(value); |
| ... | ... | @@ -67,13 +70,28 @@ export const make = Effect.gen(function* () { |
| 67 | 70 | operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents", |
| 68 | 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 | 77 | canonicalizePath(cwd), |
| 72 | 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 | ]); |
| 75 | 90 | |
| 76 | if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) { | |
| 91 | if ( | |
| 92 | isWithinRoot(candidate, workspaceRoot) || | |
| 93 | worktreesRoots.some((root) => isWithinRoot(candidate, root)) | |
| 94 | ) { | |
| 77 | 95 | return; |
| 78 | 96 | } |
| 79 | 97 |
apps/server/src/storageCleanup.ts+22-4| ... | ... | @@ -35,6 +35,7 @@ import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementSer |
| 35 | 35 | import { forkParked } from "./serverActivation.ts"; |
| 36 | 36 | import * as Settings from "./serverSettings.ts"; |
| 37 | 37 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 38 | import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; | |
| 38 | 39 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 39 | 40 | import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; |
| 40 | 41 | |
| ... | ... | @@ -176,7 +177,20 @@ export const make = Effect.gen(function* () { |
| 176 | 177 | now: number, |
| 177 | 178 | ) { |
| 178 | 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 | 194 | const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete); |
| 181 | 195 | const deletedRows = hasDeleteRule |
| 182 | 196 | ? yield* sql<{ payload_json: string; workspaceRoot: string }>` |
| ... | ... | @@ -197,7 +211,6 @@ export const make = Effect.gen(function* () { |
| 197 | 211 | resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete, |
| 198 | 212 | ); |
| 199 | 213 | const snapshot = yield* readThreads(); |
| 200 | const root = yield* fs.realPath(config.worktreesDir); | |
| 201 | 214 | const refreshedDefaultRefs = new Map<string, Set<string>>(); |
| 202 | 215 | const groups = Map.groupBy( |
| 203 | 216 | snapshot.threads.filter((thread) => thread.worktreePath !== null), |
| ... | ... | @@ -222,8 +235,13 @@ export const make = Effect.gen(function* () { |
| 222 | 235 | ) |
| 223 | 236 | continue; |
| 224 | 237 | 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; | |
| 227 | 245 | if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return; |
| 228 | 246 | // A linked worktree has a .git file. Never remove a main checkout. |
| 229 | 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 | 156 | * own t3.json. |
| 157 | 157 | */ |
| 158 | 158 | readonly submodules?: WorktreeSubmodules | null; |
| 159 | /** The `worktreesDirectory` setting, used when the input has no explicit path. */ | |
| 160 | readonly worktreesDirectory?: string; | |
| 159 | 161 | } |
| 160 | 162 | |
| 161 | 163 | export interface GitCommitProgress { |
apps/server/src/vcs/GitVcsDriverCore.test.ts+39| ... | ... | @@ -2981,6 +2981,45 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2981 | 2981 | }), |
| 2982 | 2982 | ); |
| 2983 | 2983 | |
| 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 | 3023 | it.effect("resolves the submodule mode from the option, then t3.json", () => |
| 2985 | 3024 | Effect.gen(function* () { |
| 2986 | 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 | 36 | import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; |
| 37 | 37 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| 38 | 38 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 39 | import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; | |
| 39 | 40 | import { |
| 40 | 41 | parseRemoteNames, |
| 41 | 42 | parseRemoteNamesInGitOrder, |
| ... | ... | @@ -3345,7 +3346,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3345 | 3346 | const targetBranch = input.newRefName ?? input.refName; |
| 3346 | 3347 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); |
| 3347 | 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 | 3366 | const args = input.newRefName |
| 3350 | 3367 | ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] |
| 3351 | 3368 | : ["worktree", "add", worktreePath, input.refName]; |
apps/server/src/worktreesDirectory.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import type * as Path from "effect/Path"; | |
| 2 | ||
| 3 | import { 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 | */ | |
| 12 | export 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. */ | |
| 25 | export 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. */ | |
| 30 | export 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 | 1 | import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts"; |
| 2 | 2 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 3 | import { useState } from "react"; | |
| 3 | import { useRef, useState } from "react"; | |
| 4 | 4 | |
| 5 | import { Input } from "../ui/input"; | |
| 5 | 6 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 6 | 7 | import { Switch } from "../ui/switch"; |
| 7 | 8 | import { |
| ... | ... | @@ -11,16 +12,77 @@ import { |
| 11 | 12 | NumberFieldIncrement, |
| 12 | 13 | NumberFieldInput, |
| 13 | 14 | } from "../ui/number-field"; |
| 14 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | |
| 15 | import { | |
| 16 | SettingResetButton, | |
| 17 | SettingsPageContainer, | |
| 18 | SettingsRow, | |
| 19 | SettingsSection, | |
| 20 | } from "./settingsLayout"; | |
| 15 | 21 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; |
| 16 | 22 | import type { ScopedSettingsTarget } from "./scopedSettings"; |
| 17 | 23 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 24 | import { searchableSetting } from "./settingsSearch"; | |
| 18 | 25 | import { |
| 19 | 26 | useClearScopedSettings, |
| 20 | 27 | useScopedSettings, |
| 28 | useScopedSettingsMixed, | |
| 21 | 29 | useUpdateScopedSettings, |
| 22 | 30 | } from "./useScopedSettings"; |
| 23 | 31 | |
| 32 | function 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 | ||
| 24 | 86 | function RetentionControl({ |
| 25 | 87 | label, |
| 26 | 88 | value, |
| ... | ... | @@ -152,6 +214,7 @@ export function StorageSettingsPanel() { |
| 152 | 214 | return ( |
| 153 | 215 | <SettingsPageContainer> |
| 154 | 216 | <SettingsSection id="storage-worktrees" title="Worktrees"> |
| 217 | {!isProjectScope && <WorktreesDirectoryRow />} | |
| 155 | 218 | {isProjectScope && ( |
| 156 | 219 | <SettingsRow |
| 157 | 220 | title="Automatic worktree cleanup" |
apps/web/src/components/settings/settingsSearch.ts+7| ... | ... | @@ -143,6 +143,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 143 | 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 | 154 | id: "storage-artifacts", |
| 148 | 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 | 123 | workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out |
| 124 | 124 | applies when the project and environment are both on **Inherit**. |
| 125 | 125 | |
| 126 | ## Worktree location | |
| 127 | ||
| 128 | New worktrees go in the `worktrees` folder of the T3 home directory. To put them somewhere else, | |
| 129 | such 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 | |
| 131 | are, and cleanup covers both the default folder and the custom one. | |
| 132 | ||
| 126 | 133 | ## Storage cleanup |
| 127 | 134 | |
| 128 | 135 | Open **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 | 126 | threadAutoSettlement: Schema.optionalKey(Schema.Boolean), |
| 127 | 127 | storageCleanup: Schema.optionalKey(Schema.Boolean), |
| 128 | 128 | projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), |
| 129 | /** Server honors the `worktreesDirectory` setting. */ | |
| 130 | worktreesDirectory: Schema.optionalKey(Schema.Boolean), | |
| 129 | 131 | /** Server persists the opt-in for continuing interrupted threads after restarts. */ |
| 130 | 132 | threadRestartContinuation: Schema.optionalKey(Schema.Boolean), |
| 131 | 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 | 1217 | storageCleanup: StorageCleanupSettings.pipe( |
| 1218 | 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 | 1232 | responseStreamingMode: ResponseStreamingMode.pipe( |
| 1221 | 1233 | Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), |
| 1222 | 1234 | ), |
| ... | ... | @@ -1620,6 +1632,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1620 | 1632 | logsAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1621 | 1633 | }), |
| 1622 | 1634 | ), |
| 1635 | worktreesDirectory: Schema.optionalKey(TrimmedString), | |
| 1623 | 1636 | // Server settings |
| 1624 | 1637 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), |
| 1625 | 1638 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), |
packages/shared/src/serverSettings.test.ts+13| ... | ... | @@ -795,3 +795,16 @@ describe("serverSettings helpers", () => { |
| 795 | 795 | expect(resolved.pauseWhenOnBattery).toBe(false); |
| 796 | 796 | }); |
| 797 | 797 | }); |
| 798 | ||
| 799 | describe("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 | 364 | ...(patch.providerInstances !== undefined |
| 365 | 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 | 381 | ...(projectSettingsOverridesPatch !== undefined |
| 368 | 382 | ? { |
| 369 | 383 | projectSettingsOverrides: Object.fromEntries( |