| 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,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 | }); |
| 748 | 754 | ||
| 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"; |
| 6 | import * as PlatformError from "effect/PlatformError"; | 6 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 7 | ||
| 8 | import * as ServerConfig from "../config.ts"; | 8 | import * as ServerConfig from "../config.ts"; |
| 9 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 9 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 10 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 10 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 11 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 11 | import * as ReviewService from "./ReviewService.ts"; | 12 | import * 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 | ); |
| 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 | 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 { |
| 18 | import * as ServerConfig from "../config.ts"; | 18 | import * as ServerConfig from "../config.ts"; |
| 19 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 19 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 20 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 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 | export class ReviewService extends Context.Service< | 24 | export 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; | ||
| 41 | 44 | ||
| 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 | ]); |
| 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 | return; | 95 | return; |
| 78 | } | 96 | } |
| 79 | 97 |
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 |
| 35 | import { forkParked } from "./serverActivation.ts"; | 35 | import { forkParked } from "./serverActivation.ts"; |
| 36 | import * as Settings from "./serverSettings.ts"; | 36 | import * as Settings from "./serverSettings.ts"; |
| 37 | import * as TerminalManager from "./terminal/Manager.ts"; | 37 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 38 | import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; | ||
| 38 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 39 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 39 | import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; | 40 | import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; |
| 40 | 41 | ||
| ... | @@ -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 = hasDeleteRule | 195 | 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 | } |
| 160 | 162 | ||
| 161 | export interface GitCommitProgress { | 163 | export 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 | ); |
| 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 | 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"; |
| 36 | import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; | 36 | import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; |
| 37 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; | 37 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| 38 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | 38 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 39 | import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; | ||
| 39 | import { | 40 | import { |
| 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.newRefName | 3366 | 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 @@ | ||
| 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,7 +1,8 @@ |
| 1 | import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts"; | 1 | import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts"; |
| 2 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | 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 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 6 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 6 | import { Switch } from "../ui/switch"; | 7 | import { Switch } from "../ui/switch"; |
| 7 | import { | 8 | import { |
| ... | @@ -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"; |
| 14 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | 15 | import { |
| 16 | SettingResetButton, | ||
| 17 | SettingsPageContainer, | ||
| 18 | SettingsRow, | ||
| 19 | SettingsSection, | ||
| 20 | } from "./settingsLayout"; | ||
| 15 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; | 21 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; |
| 16 | import type { ScopedSettingsTarget } from "./scopedSettings"; | 22 | import type { ScopedSettingsTarget } from "./scopedSettings"; |
| 17 | import { useSettingsScope } from "./SettingsScopeContext"; | 23 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 24 | import { searchableSetting } from "./settingsSearch"; | ||
| 18 | import { | 25 | import { |
| 19 | useClearScopedSettings, | 26 | useClearScopedSettings, |
| 20 | useScopedSettings, | 27 | useScopedSettings, |
| 28 | useScopedSettingsMixed, | ||
| 21 | useUpdateScopedSettings, | 29 | useUpdateScopedSettings, |
| 22 | } from "./useScopedSettings"; | 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 | function RetentionControl({ | 86 | function 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 | <SettingsRow | 219 | <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 |
| 123 | workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out | 123 | workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out |
| 124 | applies when the project and environment are both on **Inherit**. | 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 | ## Storage cleanup | 133 | ## Storage cleanup |
| 127 | 134 | ||
| 128 | Open **Settings → Storage** to enable automatic cleanup on one machine or all connected | 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,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 settings | 1636 | // 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 | |||
| 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,6 +364,20 @@ export function applyServerSettingsPatch( |
| 364 | ...(patch.providerInstances !== undefined | 364 | ...(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 !== undefined | 381 | ...(projectSettingsOverridesPatch !== undefined |
| 368 | ? { | 382 | ? { |
| 369 | projectSettingsOverrides: Object.fromEntries( | 383 | projectSettingsOverrides: Object.fromEntries( |