diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index b1cba460f959..5904d6c45505 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -76,6 +76,7 @@ interface ScannerTestInput { readonly importedWorkspaceRoots?: ReadonlyArray; /** Base dir for the test ServerConfig; worktreesDir derives from it. */ readonly configBaseDir?: string; + readonly worktreesDirectory?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; } @@ -88,6 +89,9 @@ const makeScannerTestLayer = (input: ScannerTestInput) => claudeAgent: { homePath: input.claudeHomePath }, codex: { homePath: input.codexHomePath }, }, + ...(input.worktreesDirectory === undefined + ? {} + : { worktreesDirectory: input.worktreesDirectory }), ...(input.providerInstances === undefined ? {} : { providerInstances: input.providerInstances }), @@ -999,6 +1003,40 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("excludes sandboxes under a custom worktrees directory without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const worktreesDirectory = yield* makeTempDir("t3code-custom-worktrees-"); + const keep = yield* makeTempDir("t3code-workspace-keep-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwd = path.join(worktreesDirectory, "t3code", "wt-custom"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-0", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-1", "b.jsonl"), + contents: claudeSessionLine(keep), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + configBaseDir, + worktreesDirectory, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]); + }), + ); + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 975192e70033..f813655c1cb3 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -52,6 +52,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { listManagedWorktreesRoots } from "../worktreesDirectory.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, @@ -540,10 +541,12 @@ function shouldRetainDecodedRecord( * transcripts look exactly like user sessions, but re-importing the app's own * sandboxes as projects is never right. Matches this server's configured * worktrees directory plus the conventional `.t3/worktrees` layout, which - * also catches sandboxes from other T3 homes on the same machine. Separators - * are normalized (and, on Windows, case folded) so the prefix match holds - * there too. Callers check both the recorded spelling and its realpath so a - * symlink into the worktrees directory cannot bypass the filter. + * also catches sandboxes from other T3 homes on the same machine. A custom + * worktrees directory is checked too, and the default root stays recognized + * after that setting changes. Separators are normalized (and, on Windows, + * case folded) so the prefix match holds there too. Callers check both the + * recorded spelling and its realpath so a symlink into the worktrees + * directory cannot bypass the filter. */ function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { const normalized = `${value.replaceAll("\\", "/")}/`; @@ -552,13 +555,14 @@ function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { function isT3ManagedWorktree( candidatePath: string, - worktreesDir: string, + worktreesDirs: ReadonlyArray, caseFold: boolean, ): boolean { const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); return ( - normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || - normalized.includes("/.t3/worktrees/") + worktreesDirs.some((worktreesDir) => + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)), + ) || normalized.includes("/.t3/worktrees/") ); } @@ -646,7 +650,14 @@ export const make = Effect.gen(function* () { path.join(homeDir, "Documents", "Codex"), ]; - const isExcludedProjectPath = (candidatePath: string) => + const resolveManagedWorktreesDirs = serverSettings.getSettings.pipe( + Effect.map((settings) => + listManagedWorktreesRoots(settings.worktreesDirectory, worktreesDir, path), + ), + Effect.orElseSucceed(() => [worktreesDir]), + ); + + const isExcludedProjectPath = (candidatePath: string, worktreesDirs: ReadonlyArray) => excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || excludedProjectAncestors.some((ancestor) => normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( @@ -656,7 +667,7 @@ export const make = Effect.gen(function* () { normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( normalizeForWorktreeMatch(baseDir, foldWorktreeCase), ) || - isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + isT3ManagedWorktree(candidatePath, worktreesDirs, foldWorktreeCase); const listDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -1200,6 +1211,7 @@ export const make = Effect.gen(function* () { const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { const { candidates: raw, truncated } = yield* collectCandidates(); + const managedWorktreesDirs = yield* resolveManagedWorktreesDirs; cachedCandidates = raw; // Filesystem identity merges symlinks and case aliases without collapsing @@ -1221,7 +1233,7 @@ export const make = Effect.gen(function* () { const expanded = expandHomePath(candidate.cwd.trim()); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); - if (isExcludedProjectPath(resolved)) continue; + if (isExcludedProjectPath(resolved, managedWorktreesDirs)) continue; let key = directoryKeys.get(resolved); if (key === undefined) { const stats = yield* statOption(resolved); @@ -1235,7 +1247,7 @@ export const make = Effect.gen(function* () { .pipe(Effect.orElseSucceed(() => resolved)); // A symlink can point into the worktrees directory even when its own // spelling doesn't; check again with links resolved. - if (isExcludedProjectPath(realPath)) { + if (isExcludedProjectPath(realPath, managedWorktreesDirs)) { key = ""; } else { const gitIdentity = yield* readGitIdentity(resolved); @@ -1331,7 +1343,13 @@ export const make = Effect.gen(function* () { ) { const root = path.resolve(expandHomePath(workspaceRoot)); const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); - if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; + const managedWorktreesDirs = yield* resolveManagedWorktreesDirs; + if ( + isExcludedProjectPath(root, managedWorktreesDirs) || + isExcludedProjectPath(realRoot, managedWorktreesDirs) + ) { + return Stream.empty; + } const rootIdentity = yield* directoryIdentity(root); const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 01a4692264e0..e304796e0360 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,9 +3,11 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ReviewService from "./ReviewService.ts"; @@ -14,6 +16,7 @@ function makeLayer(input: { readonly workspaceRoot: string; readonly baseDir: string; readonly detectCalls?: Array<{ readonly cwd: string }>; + readonly worktreesDirectory?: string; }) { return ReviewService.layer.pipe( Layer.provide( @@ -29,6 +32,13 @@ function makeLayer(input: { ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), + Layer.provideMerge( + ServerSettings.layerTest( + input.worktreesDirectory === undefined + ? {} + : { worktreesDirectory: input.worktreesDirectory }, + ), + ), Layer.provideMerge(NodeServices.layer), ); } @@ -130,4 +140,58 @@ describe("ReviewService", () => { assert.deepStrictEqual(detectCalls, []); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("allows diff preview cwd inside the default worktrees directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const worktreeCwd = path.join(baseDir, "worktrees", "repo", "branch"); + yield* fs.makeDirectory(worktreeCwd, { recursive: true }); + const detectCalls: Array<{ readonly cwd: string }> = []; + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: worktreeCwd }); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + + assert.strictEqual(result.cwd, worktreeCwd); + assert.deepStrictEqual(detectCalls, [{ cwd: worktreeCwd }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("allows diff preview cwd in a configured worktrees directory and the old default", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const configuredRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-worktrees-" }); + const configuredCwd = path.join(configuredRoot, "repo", "branch"); + const defaultCwd = path.join(baseDir, "worktrees", "repo", "old-branch"); + yield* fs.makeDirectory(configuredCwd, { recursive: true }); + yield* fs.makeDirectory(defaultCwd, { recursive: true }); + const detectCalls: Array<{ readonly cwd: string }> = []; + const layer = makeLayer({ + workspaceRoot, + baseDir, + detectCalls, + worktreesDirectory: configuredRoot, + }); + + const configuredResult = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: configuredCwd }); + }).pipe(Effect.provide(layer)); + const defaultResult = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: defaultCwd }); + }).pipe(Effect.provide(layer)); + + assert.strictEqual(configuredResult.cwd, configuredCwd); + assert.strictEqual(defaultResult.cwd, defaultCwd); + assert.deepStrictEqual(detectCalls, [{ cwd: configuredCwd }, { cwd: defaultCwd }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index dc13e5650c3c..5a77ad680613 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -16,8 +16,10 @@ import { } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import { listManagedWorktreesRoots } from "../worktreesDirectory.ts"; export class ReviewService extends Context.Service< ReviewService, @@ -67,13 +69,22 @@ export const make = Effect.gen(function* () { operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents", cwd: string, ) { - const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([ + const settings = yield* ServerSettings.ServerSettingsService; + const configured = yield* settings.getSettings.pipe( + Effect.map((current) => current.worktreesDirectory), + Effect.orElseSucceed(() => ""), + ); + const worktreesRoots = listManagedWorktreesRoots(configured, config.worktreesDir, path); + const [candidate, workspaceRoot, ...canonicalWorktreeRoots] = yield* Effect.all([ canonicalizePath(cwd), canonicalizePath(config.cwd), - canonicalizePath(config.worktreesDir), + ...worktreesRoots.map((root) => canonicalizePath(root)), ]); - if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) { + if ( + isWithinRoot(candidate, workspaceRoot) || + canonicalWorktreeRoots.some((root) => isWithinRoot(candidate, root)) + ) { return; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f9e7b51f862a..0a66c8b2d873 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -22,6 +22,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; import { makeGitVcsDriverCore, @@ -35,6 +36,7 @@ const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { }); const TestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfigLayer), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -1698,6 +1700,37 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("places default worktrees under the configured directory", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreesRoot = yield* makeTmpDir("git-configured-worktrees-"); + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ worktreesDirectory: worktreesRoot }); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: null, + refName: initialBranch, + newRefName: "feature/configured-root", + }); + + const worktreePath = path.join( + worktreesRoot, + path.basename(cwd), + "feature-configured-root", + ); + assert.equal( + yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]), + "feature/configured-root", + ); + assert.isTrue(yield* fs.exists(path.join(worktreePath, "README.md"))); + }), + ); + it.effect("uses parallel checkout without skipping filters or hooks", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f11471235f1b..53498da6a8aa 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -37,6 +37,8 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -3055,7 +3057,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + let worktreePath = input.path; + if (worktreePath === null) { + const settings = yield* ServerSettings.ServerSettingsService; + const configured = yield* settings.getSettings.pipe( + Effect.map((current) => current.worktreesDirectory), + Effect.orElseSucceed(() => ""), + ); + worktreePath = path.join( + resolveWorktreesDirectory(configured, worktreesDir, path), + repoName, + sanitizedBranch, + ); + } + yield* fileSystem + .makeDirectory(path.dirname(worktreePath), { recursive: true }) + .pipe(Effect.ignore); const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; diff --git a/apps/server/src/worktreesDirectory.test.ts b/apps/server/src/worktreesDirectory.test.ts new file mode 100644 index 000000000000..0254497475f7 --- /dev/null +++ b/apps/server/src/worktreesDirectory.test.ts @@ -0,0 +1,59 @@ +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import type * as Path from "effect/Path"; + +import { listManagedWorktreesRoots, resolveWorktreesDirectory } from "./worktreesDirectory.ts"; + +const path = { + resolve: (...segments: string[]) => NodePath.resolve(...segments), + join: (...segments: string[]) => NodePath.join(...segments), +} as Path.Path; + +const defaultWorktreesDir = NodePath.join(NodeOS.tmpdir(), "t3-default-worktrees"); + +describe("resolveWorktreesDirectory", () => { + it("uses the environment default when the setting is empty", () => { + expect(resolveWorktreesDirectory("", defaultWorktreesDir, path)).toBe( + NodePath.resolve(defaultWorktreesDir), + ); + expect(resolveWorktreesDirectory(" ", defaultWorktreesDir, path)).toBe( + NodePath.resolve(defaultWorktreesDir), + ); + }); + + it("resolves an absolute configured directory", () => { + const configured = NodePath.join(NodeOS.tmpdir(), "t3-custom-worktrees"); + expect(resolveWorktreesDirectory(configured, defaultWorktreesDir, path)).toBe( + NodePath.resolve(configured), + ); + }); + + it("expands a leading tilde", () => { + expect(resolveWorktreesDirectory("~/t3-worktrees", defaultWorktreesDir, path)).toBe( + NodePath.resolve(NodePath.join(NodeOS.homedir(), "t3-worktrees")), + ); + }); +}); + +describe("listManagedWorktreesRoots", () => { + it("returns only the default when nothing is configured", () => { + expect(listManagedWorktreesRoots("", defaultWorktreesDir, path)).toEqual([ + NodePath.resolve(defaultWorktreesDir), + ]); + }); + + it("keeps the default alongside a distinct configured directory", () => { + const configured = NodePath.join(NodeOS.tmpdir(), "t3-custom-worktrees"); + expect(listManagedWorktreesRoots(configured, defaultWorktreesDir, path)).toEqual([ + NodePath.resolve(configured), + NodePath.resolve(defaultWorktreesDir), + ]); + }); + + it("does not duplicate the default when the setting names it", () => { + expect(listManagedWorktreesRoots(defaultWorktreesDir, defaultWorktreesDir, path)).toEqual([ + NodePath.resolve(defaultWorktreesDir), + ]); + }); +}); diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts new file mode 100644 index 000000000000..49703e4c603e --- /dev/null +++ b/apps/server/src/worktreesDirectory.ts @@ -0,0 +1,41 @@ +import type * as Path from "effect/Path"; + +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +import { expandHomePathWith } from "./pathExpansion.ts"; + +/** + * Resolve the directory T3 should create new git worktrees under. + * Empty (the setting default) keeps `/worktrees`. Otherwise expand + * a leading `~` and resolve against the process cwd so a drive path like + * `D:\dev\t3\worktrees` just works. + */ +export const resolveWorktreesDirectory = ( + configured: string, + defaultWorktreesDir: string, + path: Path.Path, +): string => { + const trimmed = configured.trim(); + if (trimmed.length === 0) return path.resolve(defaultWorktreesDir); + return path.resolve(expandHomePathWith(trimmed, path)); +}; + +/** + * Roots that still count as T3-managed after the setting changes. + * Existing threads keep their absolute paths, so the previous default + * must stay recognized alongside a newly configured directory. + */ +export const listManagedWorktreesRoots = ( + configured: string, + defaultWorktreesDir: string, + path: Path.Path, +): ReadonlyArray => { + const defaultRoot = path.resolve(defaultWorktreesDir); + const resolved = resolveWorktreesDirectory(configured, defaultWorktreesDir, path); + if ( + normalizeProjectPathForComparison(resolved) === normalizeProjectPathForComparison(defaultRoot) + ) { + return [resolved]; + } + return [resolved, defaultRoot]; +}; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5238cf19e190..2d7da8a7e952 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -606,6 +606,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), + ...(settings.worktreesDirectory !== DEFAULT_UNIFIED_SETTINGS.worktreesDirectory + ? ["Worktrees directory"] + : []), ...(settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin ? ["Unpin confirmation"] : []), @@ -641,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.composerCollapseOnScroll, settings.followUpBehavior, settings.addProjectBaseDirectory, + settings.worktreesDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffFilesCollapsed, @@ -771,6 +775,7 @@ export function useSettingsRestore(onRestored?: () => void) { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + worktreesDirectory: DEFAULT_UNIFIED_SETTINGS.worktreesDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, @@ -2155,6 +2160,7 @@ export function GeneralSettingsPanel() { const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); + const mixedWorktreesDirectory = useScopedSettingsMixed(["worktreesDirectory"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); const backgroundActivityDescription = backgroundActivityProfileOption === "advanced" @@ -2828,6 +2834,35 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + worktreesDirectory: DEFAULT_UNIFIED_SETTINGS.worktreesDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ worktreesDirectory: next })} + placeholder={mixedWorktreesDirectory ? "Mixed" : ""} + spellCheck={false} + aria-label="Worktrees directory" + /> + } + /> { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults worktrees directory to empty so the environment data dir wins", () => { + expect(decodeServerSettings({}).worktreesDirectory).toBe(""); + }); + + it("accepts a worktrees directory update", () => { + expect( + decodeServerSettingsPatch({ worktreesDirectory: "D:\\dev\\t3\\worktrees" }) + .worktreesDirectory, + ).toBe("D:\\dev\\t3\\worktrees"); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { @@ -792,6 +803,7 @@ describe("ServerSettingsPatch string normalization", () => { it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ addProjectBaseDirectory: " ~/Development ", + worktreesDirectory: " D:\\dev\\t3\\worktrees ", textGenerationModelSelection: { model: " gpt-5.4-mini " }, observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", @@ -813,6 +825,7 @@ describe("ServerSettingsPatch string normalization", () => { }); expect(patch.addProjectBaseDirectory).toBe("~/Development"); + expect(patch.worktreesDirectory).toBe("D:\\dev\\t3\\worktrees"); expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); @@ -834,6 +847,7 @@ describe("ServerSettingsPatch string normalization", () => { const encoded = encodeServerSettings({ ...defaultSettings, addProjectBaseDirectory: " ~/Development ", + worktreesDirectory: " ~/t3-worktrees ", providers: { ...defaultSettings.providers, codex: { @@ -845,6 +859,7 @@ describe("ServerSettingsPatch string normalization", () => { }); expect(encoded.addProjectBaseDirectory).toBe("~/Development"); + expect(encoded.worktreesDirectory).toBe("~/t3-worktrees"); expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1c53b36037ba..83bb0156ec84 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1131,6 +1131,12 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(true)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** + * Directory new git worktrees are created under. Empty keeps them next to + * this environment's data directory. Machine-local: not shared across + * environments and not overridable per project. + */ + worktreesDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -1396,6 +1402,7 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + worktreesDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( Schema.Struct({