From ebf0b20379539bad89cbafbcd194d551609fed6c Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sat, 11 Apr 2026 12:54:46 +0300 Subject: [PATCH 1/2] add configurable worktree location settings --- .../Layers/CheckpointStore.test.ts | 9 +- apps/server/src/git/Layers/GitCore.test.ts | 11 +- apps/server/src/git/Layers/GitCore.ts | 6 +- apps/server/src/git/Layers/GitManager.test.ts | 20 +- apps/server/src/git/Layers/GitManager.ts | 17 +- apps/server/src/git/Services/GitCore.ts | 10 +- .../Layers/WorktreeLocationResolver.ts | 67 +++++ .../Services/WorktreeLocationResolver.ts | 31 +++ apps/server/src/server.test.ts | 14 +- apps/server/src/server.ts | 11 +- .../workspace/Layers/WorkspaceEntries.test.ts | 6 - .../Layers/WorkspaceFileSystem.test.ts | 6 - apps/server/src/ws.ts | 51 +++- .../settings/SettingsPanels.browser.tsx | 132 ++++++++- .../components/settings/SettingsPanels.tsx | 194 +++++++++++++- packages/contracts/src/git.ts | 14 + packages/contracts/src/rpc.ts | 3 +- packages/contracts/src/settings.ts | 30 +++ packages/shared/package.json | 4 + packages/shared/src/worktreeLocation.test.ts | 211 +++++++++++++++ packages/shared/src/worktreeLocation.ts | 252 ++++++++++++++++++ 21 files changed, 1040 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/project/Layers/WorktreeLocationResolver.ts create mode 100644 apps/server/src/project/Services/WorktreeLocationResolver.ts create mode 100644 packages/shared/src/worktreeLocation.test.ts create mode 100644 packages/shared/src/worktreeLocation.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index fe377eb1ec..fceb83b416 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -11,16 +11,9 @@ import { CheckpointStore } from "../Services/CheckpointStore.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { GitCommandError } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; import { ThreadId } from "@t3tools/contracts"; -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-checkpoint-store-test-", -}); -const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), -); +const GitCoreTestLayer = GitCoreLive.pipe(Layer.provide(NodeServices.layer)); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( Layer.provide(GitCoreTestLayer), Layer.provide(NodeServices.layer), diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 8ab541e675..8bd8726195 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -10,15 +10,10 @@ import { GitCoreLive, makeGitCore } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "@t3tools/contracts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; -import { ServerConfig } from "../../config.ts"; // ── Helpers ── -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); -const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), -); +const GitCoreTestLayer = GitCoreLive.pipe(Layer.provide(NodeServices.layer)); const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); function makeTmpDir( @@ -120,9 +115,7 @@ function runShellCommand(input: { } const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => - makeGitCore({ executeOverride }).pipe( - Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), - ); + makeGitCore({ executeOverride }).pipe(Effect.provide(NodeServices.layer)); /** Create a repo with an initial commit so branches work. */ function initRepoWithCommit( diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index fb5d908575..67c5a7caa9 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -36,7 +36,6 @@ import { parseRemoteNamesInGitOrder, parseRemoteRefWithRemoteNames, } from "../remoteRefs.ts"; -import { ServerConfig } from "../../config.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -660,7 +659,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { worktreesDir } = yield* ServerConfig; let executeRaw: GitCoreShape["execute"]; @@ -1955,9 +1953,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( function* (input) { const targetBranch = input.newBranch ?? input.branch; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const worktreePath = input.path; const args = input.newBranch ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] : ["worktree", "add", worktreePath, input.branch]; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index fd991273d1..d8b78b800e 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -20,11 +20,11 @@ import { type GitHubPullRequestSummary, GitHubCli, } from "../Services/GitHubCli.ts"; +import { WorktreeLocationResolver } from "../../project/Services/WorktreeLocationResolver.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { GitCoreLive } from "./GitCore.ts"; import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; -import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProjectSetupScriptRunner, @@ -630,19 +630,15 @@ function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; setupScriptRunner?: ProjectSetupScriptRunnerShape; + serverSettings?: Parameters[0]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-git-manager-test-", - }); - const serverSettingsLayer = ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettingsService.layerTest(input?.serverSettings); + const worktreeLocationResolverLayer = WorktreeLocationResolver.layerTest(); - const gitCoreLayer = GitCoreLive.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(ServerConfigLayer), - ); + const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(NodeServices.layer)); const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), @@ -655,6 +651,7 @@ function makeManager(input?: { ), gitCoreLayer, serverSettingsLayer, + worktreeLocationResolverLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); return makeGitManager().pipe( @@ -665,10 +662,7 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; -const GitManagerTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), - Layer.provideMerge(NodeServices.layer), -); +const GitManagerTestLayer = GitCoreLive.pipe(Layer.provideMerge(NodeServices.layer)); it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status includes PR metadata when branch already has an open PR", () => diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a84427a194..32cbfef4a5 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -45,6 +45,7 @@ import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScr import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; +import { WorktreeLocationResolver } from "../../project/Services/WorktreeLocationResolver.ts"; import { decodeGitHubPullRequestListJson, formatGitHubJsonDecodeError, @@ -495,6 +496,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const textGeneration = yield* TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const serverSettingsService = yield* ServerSettingsService; + const worktreeLocationResolver = yield* WorktreeLocationResolver; const createProgressEmitter = ( input: { cwd: string; action: GitStackedAction }, @@ -616,6 +618,15 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); + const resolveCreateWorktreePath = Effect.fn("GitManager.resolveCreateWorktreePath")( + function* (input: { projectRoot: string; branch: string; newBranch?: string }) { + return yield* worktreeLocationResolver.resolveCreateWorktreePath({ + projectRoot: input.projectRoot, + name: input.newBranch ?? input.branch, + }); + }, + ); + const materializePullRequestHeadBranch = ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, @@ -1486,10 +1497,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); } + const worktreePath = yield* resolveCreateWorktreePath({ + projectRoot: input.cwd, + branch: localPullRequestBranch, + }); const worktree = yield* gitCore.createWorktree({ cwd: input.cwd, branch: localPullRequestBranch, - path: null, + path: worktreePath, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 9f3bc0b9b9..644d00844e 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -13,7 +13,6 @@ import type { GitCheckoutResult, GitCreateBranchInput, GitCreateBranchResult, - GitCreateWorktreeInput, GitCreateWorktreeResult, GitInitInput, GitListBranchesInput, @@ -26,6 +25,13 @@ import type { import type { GitCommandError } from "@t3tools/contracts"; +export interface GitCoreCreateWorktreeInput { + readonly cwd: string; + readonly branch: string; + readonly newBranch?: string; + readonly path: string; +} + export interface ExecuteGitInput { readonly operation: string; readonly cwd: string; @@ -241,7 +247,7 @@ export interface GitCoreShape { * Create a worktree and branch from a base branch. */ readonly createWorktree: ( - input: GitCreateWorktreeInput, + input: GitCoreCreateWorktreeInput, ) => Effect.Effect; /** diff --git a/apps/server/src/project/Layers/WorktreeLocationResolver.ts b/apps/server/src/project/Layers/WorktreeLocationResolver.ts new file mode 100644 index 0000000000..e9d0a3e191 --- /dev/null +++ b/apps/server/src/project/Layers/WorktreeLocationResolver.ts @@ -0,0 +1,67 @@ +import { Effect, Layer } from "effect"; +import { WorktreeLocationResolverError } from "@t3tools/contracts"; + +import { + createWorktreeLocationTemplateContext, + resolveWorktreeLocation, + sanitizeWorktreeName, +} from "@t3tools/shared/worktreeLocation"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + WorktreeLocationResolver, + type WorktreeLocationResolverShape, +} from "../Services/WorktreeLocationResolver.ts"; + +function createWorktreeLocationResolverError( + projectRoot: string, + detail: string, + cause?: unknown, +): WorktreeLocationResolverError { + return new WorktreeLocationResolverError({ + projectRoot, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +export const makeWorktreeLocationResolver = Effect.gen(function* () { + const { baseDir, worktreesDir } = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + + const resolveCreateWorktreePath: WorktreeLocationResolverShape["resolveCreateWorktreePath"] = + Effect.fn("WorktreeLocationResolver.resolveCreateWorktreePath")(function* (input) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((error) => + createWorktreeLocationResolverError(input.projectRoot, error.message, error), + ), + ); + const resolvedLocation = resolveWorktreeLocation({ + mode: settings.worktreeLocation.mode, + template: settings.worktreeLocation.template, + context: createWorktreeLocationTemplateContext({ + t3Home: baseDir, + projectRoot: input.projectRoot, + worktreeName: sanitizeWorktreeName(input.name), + }), + defaultWorktreesDir: worktreesDir, + }); + if (!resolvedLocation.ok) { + return yield* createWorktreeLocationResolverError( + input.projectRoot, + `invalid custom worktree template: ${resolvedLocation.error}`, + ); + } + + return resolvedLocation.path; + }); + + return { + resolveCreateWorktreePath, + } satisfies WorktreeLocationResolverShape; +}); + +export const WorktreeLocationResolverLive = Layer.effect( + WorktreeLocationResolver, + makeWorktreeLocationResolver, +); diff --git a/apps/server/src/project/Services/WorktreeLocationResolver.ts b/apps/server/src/project/Services/WorktreeLocationResolver.ts new file mode 100644 index 0000000000..e5297ef0b0 --- /dev/null +++ b/apps/server/src/project/Services/WorktreeLocationResolver.ts @@ -0,0 +1,31 @@ +import { WorktreeLocationResolverError } from "@t3tools/contracts"; +import { Effect, Context, Layer } from "effect"; +import path from "node:path"; + +export interface ResolveCreateWorktreePathInput { + readonly projectRoot: string; + readonly name: string; +} + +export interface WorktreeLocationResolverShape { + readonly resolveCreateWorktreePath: ( + input: ResolveCreateWorktreePathInput, + ) => Effect.Effect; +} + +export class WorktreeLocationResolver extends Context.Service< + WorktreeLocationResolver, + WorktreeLocationResolverShape +>()("t3/project/Services/WorktreeLocationResolver") { + static readonly layerTest = ({ + resolveCreateWorktreePath = (input) => + Effect.succeed( + path.join(input.projectRoot, ".mock-worktrees", input.name.replace(/\//g, "-")), + ), + }: { + resolveCreateWorktreePath?: WorktreeLocationResolverShape["resolveCreateWorktreePath"]; + } = {}) => + Layer.succeed(WorktreeLocationResolver, { + resolveCreateWorktreePath, + }); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fbbab1c84a..39ef9ea7cf 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -57,6 +57,10 @@ import { import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; +import { + WorktreeLocationResolver, + type WorktreeLocationResolverShape, +} from "./project/Services/WorktreeLocationResolver.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -293,6 +297,7 @@ const buildAppUnderTest = (options?: { open?: Partial; gitCore?: Partial; gitManager?: Partial; + worktreeLocationResolver?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -385,6 +390,13 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitCore, }), ), + Layer.provide( + Layer.mock(WorktreeLocationResolver)({ + resolveCreateWorktreePath: (input) => + Effect.succeed(`/tmp/worktrees/${input.name.replace(/\//g, "-")}`), + ...options?.layers?.worktreeLocationResolver, + }), + ), Layer.provide(gitManagerLayer), Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( @@ -2997,7 +3009,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: "/tmp/project", branch: "main", newBranch: "t3code/bootstrap-branch", - path: null, + path: "/tmp/worktrees/t3code-bootstrap-branch", }); assert.deepEqual(runForThread.mock.calls[0]?.[0], { threadId: ThreadId.make("thread-bootstrap"), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 23c53ad07f..5a1d8fae54 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -29,6 +29,7 @@ import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; +import { WorktreeLocationResolverLive } from "./project/Layers/WorktreeLocationResolver"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { GitManagerLive } from "./git/Layers/GitManager"; import { KeybindingsLive } from "./keybindings"; @@ -165,9 +166,14 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const GitCoreLayerLive = GitCoreLive; +const WorktreeLocationLayerLive = WorktreeLocationResolverLive.pipe( + Layer.provideMerge(ServerSettingsLive), +); + const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitCoreLayerLive), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(RoutingTextGenerationLive), ); @@ -175,7 +181,7 @@ const GitManagerLayerLive = GitManagerLive.pipe( const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitCoreLayerLive), ); const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); @@ -205,6 +211,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(ServerSettingsLive), + Layer.provideMerge(WorktreeLocationLayerLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 09f6905ce9..b69078bd80 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -4,7 +4,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; -import { ServerConfig } from "../../config.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; @@ -15,11 +14,6 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(GitCoreLive), - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-workspace-entries-test-", - }), - ), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fcfd13c912..40a2db2301 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { ServerConfig } from "../../config.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; @@ -20,11 +19,6 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(GitCoreLive), - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-workspace-files-test-", - }), - ), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3ef4a86469..1dca13be22 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -30,6 +30,7 @@ import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore"; import { GitManager } from "./git/Services/GitManager"; import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; +import { WorktreeLocationResolver } from "./project/Services/WorktreeLocationResolver"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { normalizeDispatchCommand } from "./orchestration/Normalizer"; @@ -112,12 +113,31 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const open = yield* Open; const gitManager = yield* GitManager; const git = yield* GitCore; + const worktreeLocationResolver = yield* WorktreeLocationResolver; const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; + + const resolveCreateWorktreePath = Effect.fn("ws.resolveCreateWorktreePath")( + function* (input: { + cwd: string; + branch: string; + newBranch?: string; + path: string | null; + }) { + if (input.path !== null) { + return input.path; + } + + return yield* worktreeLocationResolver.resolveCreateWorktreePath({ + projectRoot: input.cwd, + name: input.newBranch ?? input.branch, + }); + }, + ); const startup = yield* ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; @@ -376,11 +396,21 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => } if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ + const worktreePath = yield* resolveCreateWorktreePath({ cwd: bootstrap.prepareWorktree.projectCwd, branch: bootstrap.prepareWorktree.baseBranch, - newBranch: bootstrap.prepareWorktree.branch, path: null, + ...(bootstrap.prepareWorktree.branch + ? { newBranch: bootstrap.prepareWorktree.branch } + : {}), + }); + const worktree = yield* git.createWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + path: worktreePath, + ...(bootstrap.prepareWorktree.branch + ? { newBranch: bootstrap.prepareWorktree.branch } + : {}), }); targetWorktreePath = worktree.worktree.path; yield* orchestrationEngine.dispatch({ @@ -755,7 +785,22 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => [WS_METHODS.gitCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.gitCreateWorktree, - git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + Effect.gen(function* () { + const worktreePath = yield* resolveCreateWorktreePath({ + cwd: input.cwd, + branch: input.branch, + path: input.path, + ...(input.newBranch ? { newBranch: input.newBranch } : {}), + }); + return yield* git + .createWorktree({ + path: worktreePath, + cwd: input.cwd, + branch: input.branch, + ...(input.newBranch ? { newBranch: input.newBranch } : {}), + }) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))); + }), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitRemoveWorktree]: (input) => diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 0337da23a4..e29506771c 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -200,6 +200,25 @@ function createBaseServerConfig(): ServerConfig { }; } +function installLocalApiStub() { + window.nativeApi = { + dialogs: { + confirm: vi.fn().mockResolvedValue(true), + }, + persistence: { + getClientSettings: vi.fn().mockResolvedValue(null), + setClientSettings: vi.fn().mockResolvedValue(undefined), + }, + server: { + refreshProviders: vi.fn().mockResolvedValue(undefined), + updateSettings: vi.fn().mockResolvedValue(DEFAULT_SERVER_SETTINGS), + }, + shell: { + openInEditor: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as LocalApi; +} + function makeUtc(value: string) { return DateTime.makeUnsafe(Date.parse(value)); } @@ -334,6 +353,7 @@ describe("GeneralSettingsPanel observability", () => { await __resetLocalApiForTests(); localStorage.clear(); authAccessHarness.reset(); + installLocalApiStub(); }); afterEach(async () => { @@ -344,7 +364,6 @@ describe("GeneralSettingsPanel observability", () => { mounted = null; vi.unstubAllGlobals(); Reflect.deleteProperty(window, "desktopBridge"); - Reflect.deleteProperty(window, "nativeApi"); document.body.innerHTML = ""; resetServerStateForTests(); await __resetLocalApiForTests(); @@ -691,4 +710,115 @@ describe("GeneralSettingsPanel observability", () => { expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); }); + + it("renders the worktree location row with the default preview", async () => { + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + await expect.element(page.getByText("Worktree location")).toBeInTheDocument(); + await expect + .element(page.getByText("~/.t3/worktrees/project/feature-branch", { exact: true })) + .toBeInTheDocument(); + }); + + it("updates the preview for each built-in worktree location mode", async () => { + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + const trigger = page.getByLabelText("Worktree location mode"); + + await trigger.click(); + await page.getByRole("option", { name: "Project-subdirectory", exact: true }).click(); + await expect + .element(page.getByText("/repo/project.worktrees/feature-branch", { exact: true })) + .toBeInTheDocument(); + + await trigger.click(); + await page.getByRole("option", { name: "Project-sibling", exact: true }).click(); + await expect + .element(page.getByText("/repo/project.feature-branch", { exact: true })) + .toBeInTheDocument(); + }); + + it("reveals custom template controls, validates inline, and updates the preview", async () => { + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + const trigger = page.getByLabelText("Worktree location mode"); + await trigger.click(); + await page.getByRole("option", { name: "Custom", exact: true }).click(); + + await expect.element(page.getByLabelText("Custom worktree template")).toBeInTheDocument(); + await expect.element(page.getByText("$T3_HOME", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("$PROJECT_DIRNAME", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("$PROJECT_NAME", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("$WORKTREE_NAME", { exact: true })).toBeInTheDocument(); + + const templateInput = page.getByLabelText("Custom worktree template"); + await expect + .element(templateInput) + .toHaveValue("$PROJECT_DIRNAME/$PROJECT_NAME.$WORKTREE_NAME"); + + await templateInput.fill("$PROJECT_NAME/$WORKTREE_NAME"); + await expect + .element( + page.getByText( + "Custom worktree templates must start with $T3_HOME, $PROJECT_DIRNAME, /, or \\.", + ), + ) + .toBeInTheDocument(); + + await templateInput.fill("/custom/$PROJECT_DIRNAME/$WORKTREE_NAME"); + await expect + .element( + page.getByText( + "$T3_HOME and $PROJECT_DIRNAME can only appear at the start of the template.", + ), + ) + .toBeInTheDocument(); + + await templateInput.fill("$T3_HOME/custom/$PROJECT_NAME/$WORKTREE_NAME"); + await expect + .element(page.getByText("~/.t3/custom/project/feature-branch", { exact: true })) + .toBeInTheDocument(); + }); + + it("resets worktree location mode and template back to defaults", async () => { + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + const trigger = page.getByLabelText("Worktree location mode"); + await trigger.click(); + await page.getByRole("option", { name: "Custom", exact: true }).click(); + + const templateInput = page.getByLabelText("Custom worktree template"); + await templateInput.fill("$T3_HOME/custom/$PROJECT_NAME/$WORKTREE_NAME"); + + await page.getByLabelText("Reset worktree location to default").click(); + + await expect + .element(page.getByText("~/.t3/worktrees/project/feature-branch", { exact: true })) + .toBeInTheDocument(); + await expect.element(page.getByLabelText("Custom worktree template")).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8766f2643..fbb2ef772d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -9,17 +9,23 @@ import { XIcon, } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, type ScopedThreadRef, type ProviderKind, type ServerProvider, type ServerProviderModel, + type WorktreeLocationMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { + createWorktreeLocationTemplateContext, + renderWorktreeLocationPreview, + WORKTREE_LOCATION_TEMPLATE_VARIABLES, +} from "@t3tools/shared/worktreeLocation"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; import { @@ -74,6 +80,7 @@ import { useServerAvailableEditors, useServerKeybindingsConfigPath, useServerObservability, + useServerConfig, useServerProviders, } from "../../rpc/serverState"; @@ -98,6 +105,17 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const WORKTREE_LOCATION_MODE_LABELS: Record = { + default: "Default", + "project-subdirectory": "Project-subdirectory", + "project-sibling": "Project-sibling", + custom: "Custom", +}; + +const PREVIEW_T3_HOME = "~/.t3"; +const PREVIEW_WORKTREE_NAME = "feature-branch"; +const PREVIEW_PROJECT_CWD_FALLBACK = "/code/my-project"; + type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -377,6 +395,12 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.worktreeLocation.mode !== DEFAULT_UNIFIED_SETTINGS.worktreeLocation.mode + ? ["Worktree location"] + : []), + ...(settings.worktreeLocation.template !== DEFAULT_UNIFIED_SETTINGS.worktreeLocation.template + ? ["Custom worktree template"] + : []), ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? ["Archive confirmation"] : []), @@ -394,6 +418,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.worktreeLocation.mode, + settings.worktreeLocation.template, settings.timestampFormat, theme, ], @@ -424,6 +450,7 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); + const serverConfig = useServerConfig(); const [openingPathByTarget, setOpeningPathByTarget] = useState({ keybindings: false, logsDirectory: false, @@ -502,6 +529,61 @@ export function GeneralSettingsPanel() { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const worktreeLocation = settings.worktreeLocation; + const defaultWorktreeLocation = DEFAULT_UNIFIED_SETTINGS.worktreeLocation; + const [worktreeLocationTemplateDraft, setWorktreeLocationTemplateDraft] = useState( + worktreeLocation.template, + ); + const previewContext = useMemo( + () => + createWorktreeLocationTemplateContext({ + t3Home: PREVIEW_T3_HOME, + projectRoot: serverConfig?.cwd ?? PREVIEW_PROJECT_CWD_FALLBACK, + worktreeName: PREVIEW_WORKTREE_NAME, + }), + [serverConfig?.cwd], + ); + const worktreeLocationPreview = useMemo( + () => + renderWorktreeLocationPreview({ + mode: worktreeLocation.mode, + template: + worktreeLocation.mode === "custom" + ? worktreeLocationTemplateDraft + : worktreeLocation.template, + context: previewContext, + }), + [ + previewContext, + worktreeLocation.mode, + worktreeLocation.template, + worktreeLocationTemplateDraft, + ], + ); + const isWorktreeLocationDirty = !Equal.equals(worktreeLocation, defaultWorktreeLocation); + + useEffect(() => { + setWorktreeLocationTemplateDraft(worktreeLocation.template); + }, [worktreeLocation.template]); + + const updateWorktreeLocation = useCallback( + (patch: Partial) => { + updateSettings({ + worktreeLocation: { + ...worktreeLocation, + ...patch, + }, + }); + }, + [updateSettings, worktreeLocation], + ); + + const commitWorktreeLocationTemplateDraft = useCallback(() => { + if (worktreeLocationTemplateDraft === worktreeLocation.template) { + return; + } + updateWorktreeLocation({ template: worktreeLocationTemplateDraft }); + }, [updateWorktreeLocation, worktreeLocation.template, worktreeLocationTemplateDraft]); const openInPreferredEditor = useCallback( (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => { @@ -852,6 +934,116 @@ export function GeneralSettingsPanel() { } /> + + Preview:{" "} + + {worktreeLocationPreview.preview} + + + ) : null + } + resetAction={ + isWorktreeLocationDirty ? ( + updateWorktreeLocation(defaultWorktreeLocation)} + /> + ) : null + } + control={ + + } + > + {worktreeLocation.mode === "custom" ? ( +
+ +
+ {WORKTREE_LOCATION_TEMPLATE_VARIABLES.map((variable) => ( + + {variable.token} + + ))} +
+

+ {worktreeLocationPreview.error ?? + "Literal substitution only. Include $WORKTREE_NAME in the final path template."} +

+
+ ) : null} +
+ ()( } } +export class WorktreeLocationResolverError extends Schema.TaggedErrorClass()( + "WorktreeLocationResolverError", + { + projectRoot: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Worktree location resolution failed for ${this.projectRoot}: ${this.detail}`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, GitCommandError, GitHubCliError, TextGenerationError, + WorktreeLocationResolverError, ]); export type GitManagerServiceError = typeof GitManagerServiceError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f47b427bcd..35b6268dd6 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -22,6 +22,7 @@ import { GitPullInput, GitPullRequestRefInput, GitPullResult, + WorktreeLocationResolverError, GitRemoveWorktreeInput, GitResolvePullRequestResult, GitRunStackedActionInput, @@ -215,7 +216,7 @@ export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { payload: GitCreateWorktreeInput, success: GitCreateWorktreeResult, - error: GitCommandError, + error: Schema.Union([GitCommandError, WorktreeLocationResolverError]), }); export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee56..83ada74b2f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -46,6 +46,29 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; +export const WorktreeLocationMode = Schema.Literals([ + "default", + "project-subdirectory", + "project-sibling", + "custom", +]); +export type WorktreeLocationMode = typeof WorktreeLocationMode.Type; +export const DEFAULT_WORKTREE_LOCATION_MODE: WorktreeLocationMode = "default"; +export const DEFAULT_WORKTREE_LOCATION_TEMPLATE = "$PROJECT_DIRNAME/$PROJECT_NAME.$WORKTREE_NAME"; + +export const WorktreeLocationTemplate = TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_LOCATION_TEMPLATE)), +); +export type WorktreeLocationTemplate = typeof WorktreeLocationTemplate.Type; + +export const WorktreeLocationSettings = Schema.Struct({ + mode: WorktreeLocationMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_LOCATION_MODE)), + ), + template: WorktreeLocationTemplate, +}).pipe(Schema.withDecodingDefault(Effect.succeed({}))); +export type WorktreeLocationSettings = typeof WorktreeLocationSettings.Type; + const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( @@ -84,6 +107,7 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + worktreeLocation: WorktreeLocationSettings, textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -165,9 +189,15 @@ const ClaudeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const WorktreeLocationSettingsPatch = Schema.Struct({ + mode: Schema.optionalKey(WorktreeLocationMode), + template: Schema.optionalKey(Schema.String), +}); + export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + worktreeLocation: Schema.optionalKey(WorktreeLocationSettingsPatch), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({ diff --git a/packages/shared/package.json b/packages/shared/package.json index ed65cbeaf3..1078e6b5fa 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -44,6 +44,10 @@ "types": "./src/serverSettings.ts", "import": "./src/serverSettings.ts" }, + "./worktreeLocation": { + "types": "./src/worktreeLocation.ts", + "import": "./src/worktreeLocation.ts" + }, "./String": { "types": "./src/String.ts", "import": "./src/String.ts" diff --git a/packages/shared/src/worktreeLocation.test.ts b/packages/shared/src/worktreeLocation.test.ts new file mode 100644 index 0000000000..56931f61ae --- /dev/null +++ b/packages/shared/src/worktreeLocation.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { + createWorktreeLocationTemplateContext, + getDefaultWorktreesDir, + renderWorktreeLocationPreview, + resolveCustomWorktreeLocationTemplate, + resolveWorktreeLocation, + substituteWorktreeLocationTemplate, + validateWorktreeLocationTemplate, +} from "./worktreeLocation"; + +describe("worktreeLocation helpers", () => { + it("validates start-of-path and $WORKTREE_NAME requirements", () => { + expect(validateWorktreeLocationTemplate(" ")).toBe("Enter a full path template."); + expect(validateWorktreeLocationTemplate("$PROJECT_NAME/$WORKTREE_NAME")).toBe( + "Custom worktree templates must start with $T3_HOME, $PROJECT_DIRNAME, /, or \\.", + ); + expect(validateWorktreeLocationTemplate("$PROJECT_DIRNAME/custom")).toBe( + "Custom worktree templates must include $WORKTREE_NAME.", + ); + expect(validateWorktreeLocationTemplate("$PROJECT_DIRNAME/$WORKTREE_NAME")).toBeNull(); + }); + + it("rejects absolute path variables outside the start of the template", () => { + expect(validateWorktreeLocationTemplate("/custom/$T3_HOME/$WORKTREE_NAME")).toBe( + "$T3_HOME and $PROJECT_DIRNAME can only appear at the start of the template.", + ); + expect( + validateWorktreeLocationTemplate("$PROJECT_DIRNAME/custom/$PROJECT_DIRNAME/$WORKTREE_NAME"), + ).toBe("$T3_HOME and $PROJECT_DIRNAME can only appear at the start of the template."); + }); + + it("substitutes all supported variables", () => { + const context = createWorktreeLocationTemplateContext({ + t3Home: "/home/dev/.t3", + projectRoot: "/code/my-project", + worktreeName: "feature-branch", + }); + + expect( + substituteWorktreeLocationTemplate( + "$T3_HOME|$PROJECT_DIRNAME|$PROJECT_NAME|$WORKTREE_NAME", + context, + ), + ).toBe("/home/dev/.t3|/code|my-project|feature-branch"); + }); + + it("resolves custom templates through one shared helper", () => { + const context = createWorktreeLocationTemplateContext({ + t3Home: "~/.t3", + projectRoot: "/code/my-project", + worktreeName: "feature-branch", + }); + + expect( + resolveCustomWorktreeLocationTemplate({ + template: "$PROJECT_DIRNAME/$PROJECT_NAME.$WORKTREE_NAME", + context, + }), + ).toEqual({ + ok: true, + path: "/code/my-project.feature-branch", + }); + + expect( + resolveCustomWorktreeLocationTemplate({ + template: "$PROJECT_NAME/$WORKTREE_NAME", + context, + }), + ).toEqual({ + ok: false, + error: "Custom worktree templates must start with $T3_HOME, $PROJECT_DIRNAME, /, or \\.", + }); + }); + + it("resolves built-in and custom worktree modes through one shared helper", () => { + const context = createWorktreeLocationTemplateContext({ + t3Home: "/home/dev/.t3", + projectRoot: "/code/my-project", + worktreeName: "feature-branch", + }); + + expect( + resolveWorktreeLocation({ + mode: "default", + template: "", + context, + defaultWorktreesDir: getDefaultWorktreesDir(context.$T3_HOME), + }), + ).toEqual({ + ok: true, + path: "/home/dev/.t3/worktrees/my-project/feature-branch", + }); + + expect( + resolveWorktreeLocation({ + mode: "project-subdirectory", + template: "", + context, + defaultWorktreesDir: getDefaultWorktreesDir(context.$T3_HOME), + }), + ).toEqual({ + ok: true, + path: "/code/my-project.worktrees/feature-branch", + }); + + expect( + resolveWorktreeLocation({ + mode: "project-sibling", + template: "", + context, + defaultWorktreesDir: getDefaultWorktreesDir(context.$T3_HOME), + }), + ).toEqual({ + ok: true, + path: "/code/my-project.feature-branch", + }); + + expect( + resolveWorktreeLocation({ + mode: "custom", + template: "$PROJECT_DIRNAME/$PROJECT_NAME.worktrees/$WORKTREE_NAME", + context, + defaultWorktreesDir: getDefaultWorktreesDir(context.$T3_HOME), + }), + ).toEqual({ + ok: true, + path: "/code/my-project.worktrees/feature-branch", + }); + }); + + it("requires a token boundary after absolute path variables at the start", () => { + expect(validateWorktreeLocationTemplate("$T3_HOME_SUFFIX/$WORKTREE_NAME")).toBe( + "Custom worktree templates must start with $T3_HOME, $PROJECT_DIRNAME, /, or \\.", + ); + expect(validateWorktreeLocationTemplate("$PROJECT_DIRNAME_SUFFIX/$WORKTREE_NAME")).toBe( + "Custom worktree templates must start with $T3_HOME, $PROJECT_DIRNAME, /, or \\.", + ); + }); + + it("rejects invalid custom previews", () => { + const context = createWorktreeLocationTemplateContext({ + t3Home: "~/.t3", + projectRoot: "/code/my-project", + worktreeName: "feature-branch", + }); + + expect( + renderWorktreeLocationPreview({ + mode: "custom", + template: " ", + context, + }), + ).toEqual({ + preview: null, + error: "Enter a full path template.", + }); + }); + + it("renders symbolic preview values for built-in and custom modes", () => { + const context = createWorktreeLocationTemplateContext({ + t3Home: "~/.t3", + projectRoot: "/code/my-project", + worktreeName: "feature-branch", + }); + + expect( + renderWorktreeLocationPreview({ + mode: "default", + template: "", + context, + }), + ).toEqual({ + preview: "~/.t3/worktrees/my-project/feature-branch", + error: null, + }); + + expect( + renderWorktreeLocationPreview({ + mode: "project-subdirectory", + template: "", + context, + }), + ).toEqual({ + preview: "/code/my-project.worktrees/feature-branch", + error: null, + }); + + expect( + renderWorktreeLocationPreview({ + mode: "project-sibling", + template: "", + context, + }), + ).toEqual({ + preview: "/code/my-project.feature-branch", + error: null, + }); + + expect( + renderWorktreeLocationPreview({ + mode: "custom", + template: "$PROJECT_DIRNAME/$PROJECT_NAME.$WORKTREE_NAME", + context, + }), + ).toEqual({ + preview: "/code/my-project.feature-branch", + error: null, + }); + }); +}); diff --git a/packages/shared/src/worktreeLocation.ts b/packages/shared/src/worktreeLocation.ts new file mode 100644 index 0000000000..6180880073 --- /dev/null +++ b/packages/shared/src/worktreeLocation.ts @@ -0,0 +1,252 @@ +import type { WorktreeLocationMode } from "@t3tools/contracts"; + +export const WORKTREE_LOCATION_TEMPLATE_VARIABLES = [ + { + token: "$T3_HOME", + description: "Base T3 Code home directory.", + }, + { + token: "$PROJECT_DIRNAME", + description: "Directory containing the project root.", + }, + { + token: "$PROJECT_NAME", + description: "Final path segment of the project root.", + }, + { + token: "$WORKTREE_NAME", + description: "Sanitized worktree branch name.", + }, +] as const; + +export type WorktreeLocationTemplateVariable = + (typeof WORKTREE_LOCATION_TEMPLATE_VARIABLES)[number]["token"]; + +export type WorktreeLocationTemplateContext = Record; + +export interface WorktreeLocationPreview { + readonly preview: string | null; + readonly error: string | null; +} + +export interface ResolveWorktreeLocationInput { + readonly mode: WorktreeLocationMode; + readonly template: string; + readonly context: WorktreeLocationTemplateContext; + readonly defaultWorktreesDir: string; +} + +export type ResolvedWorktreeLocation = + | { + readonly ok: true; + readonly path: string; + } + | { + readonly ok: false; + readonly error: string; + }; + +const TRAILING_SEPARATOR_PATTERN = /[\\/]+$/; +const LEADING_SEPARATOR_PATTERN = /^[\\/]+/; +const ABSOLUTE_TEMPLATE_VARIABLE_PREFIXES = ["$T3_HOME", "$PROJECT_DIRNAME"] as const; +const ABSOLUTE_TEMPLATE_VARIABLES = ["$T3_HOME", "$PROJECT_DIRNAME"] as const; + +function trimTrailingSeparators(input: string): string { + if (input === "/" || /^[A-Za-z]:[\\/]?$/.test(input)) { + return input; + } + return input.replace(TRAILING_SEPARATOR_PATTERN, ""); +} + +function detectPathSeparator(input: string): "/" | "\\" { + return input.includes("\\") && !input.includes("/") ? "\\" : "/"; +} + +function getPathSegments(input: string): string[] { + return trimTrailingSeparators(input) + .split(/[\\/]+/g) + .filter((segment) => segment.length > 0); +} + +function getPathBasename(input: string): string { + const segments = getPathSegments(input); + return segments.at(-1) ?? input; +} + +function getPathDirname(input: string): string { + const trimmed = trimTrailingSeparators(input); + if (trimmed === "/" || /^[A-Za-z]:[\\/]?$/.test(trimmed)) { + return trimmed; + } + + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + if (separatorIndex <= 0) { + if (/^[A-Za-z]:[^\\/]+$/.test(trimmed)) { + return `${trimmed.slice(0, 2)}\\`; + } + return separatorIndex === 0 ? trimmed.slice(0, 1) : "."; + } + + return trimmed.slice(0, separatorIndex); +} + +function joinPreviewPath(separator: "/" | "\\", ...parts: string[]): string { + const firstPart = parts.find((part) => part.length > 0); + if (!firstPart) return ""; + const restParts = parts.slice(parts.indexOf(firstPart) + 1); + + let result = firstPart; + for (const part of restParts) { + const normalizedPart = part + .replace(LEADING_SEPARATOR_PATTERN, "") + .replace(TRAILING_SEPARATOR_PATTERN, ""); + if (normalizedPart.length === 0) continue; + if (!result.endsWith("/") && !result.endsWith("\\")) { + result += separator; + } + result += normalizedPart; + } + return result; +} + +function startsWithAbsoluteTemplatePrefix(input: string): boolean { + if (input.startsWith("/") || input.startsWith("\\")) { + return true; + } + return ABSOLUTE_TEMPLATE_VARIABLE_PREFIXES.some((variable) => { + if (!input.startsWith(variable)) { + return false; + } + const nextChar = input.slice(variable.length, variable.length + 1); + return nextChar.length === 0 || nextChar === "/" || nextChar === "\\"; + }); +} + +export function getDefaultWorktreesDir(t3Home: string): string { + return joinPreviewPath(detectPathSeparator(t3Home), t3Home, "worktrees"); +} + +export function createWorktreeLocationTemplateContext(input: { + readonly t3Home: string; + readonly projectRoot: string; + readonly worktreeName: string; +}): WorktreeLocationTemplateContext { + return { + $T3_HOME: input.t3Home, + $PROJECT_DIRNAME: getPathDirname(input.projectRoot), + $PROJECT_NAME: getPathBasename(input.projectRoot), + $WORKTREE_NAME: input.worktreeName, + }; +} + +export function sanitizeWorktreeName(name: string): string { + return name.replace(/\//g, "-"); +} + +export function validateWorktreeLocationTemplate(template: string): string | null { + const normalized = template.trim(); + if (normalized.length === 0) { + return "Enter a full path template."; + } + if (!startsWithAbsoluteTemplatePrefix(normalized)) { + return "Custom worktree templates must start with $T3_HOME, $PROJECT_DIRNAME, /, or \\."; + } + if ( + ABSOLUTE_TEMPLATE_VARIABLES.some((variable) => { + const firstIndex = normalized.indexOf(variable); + return firstIndex > 0 || normalized.indexOf(variable, variable.length) >= 0; + }) + ) { + return "$T3_HOME and $PROJECT_DIRNAME can only appear at the start of the template."; + } + if (!normalized.includes("$WORKTREE_NAME")) { + return "Custom worktree templates must include $WORKTREE_NAME."; + } + return null; +} + +export function substituteWorktreeLocationTemplate( + template: string, + context: WorktreeLocationTemplateContext, +): string { + const normalized = template.trim(); + let resolved = normalized; + for (const variable of WORKTREE_LOCATION_TEMPLATE_VARIABLES) { + resolved = resolved.split(variable.token).join(context[variable.token]); + } + return resolved; +} + +export function resolveCustomWorktreeLocationTemplate(input: { + readonly template: string; + readonly context: WorktreeLocationTemplateContext; +}): ResolvedWorktreeLocation { + const error = validateWorktreeLocationTemplate(input.template); + if (error) { + return { + ok: false, + error, + }; + } + return { + ok: true, + path: substituteWorktreeLocationTemplate(input.template, input.context), + }; +} + +export function resolveWorktreeLocation( + input: ResolveWorktreeLocationInput, +): ResolvedWorktreeLocation { + const { context, defaultWorktreesDir, mode, template } = input; + const projectSeparator = detectPathSeparator(context.$PROJECT_DIRNAME); + const defaultWorktreesSeparator = detectPathSeparator(defaultWorktreesDir); + + switch (mode) { + case "default": + return { + ok: true, + path: joinPreviewPath( + defaultWorktreesSeparator, + defaultWorktreesDir, + context.$PROJECT_NAME, + context.$WORKTREE_NAME, + ), + }; + case "project-subdirectory": + return { + ok: true, + path: joinPreviewPath( + projectSeparator, + context.$PROJECT_DIRNAME, + `${context.$PROJECT_NAME}.worktrees`, + context.$WORKTREE_NAME, + ), + }; + case "project-sibling": + return { + ok: true, + path: joinPreviewPath( + projectSeparator, + context.$PROJECT_DIRNAME, + `${context.$PROJECT_NAME}.${context.$WORKTREE_NAME}`, + ), + }; + case "custom": + return resolveCustomWorktreeLocationTemplate({ template, context }); + } +} + +export function renderWorktreeLocationPreview(input: { + readonly mode: WorktreeLocationMode; + readonly template: string; + readonly context: WorktreeLocationTemplateContext; +}): WorktreeLocationPreview { + const resolvedLocation = resolveWorktreeLocation({ + ...input, + defaultWorktreesDir: getDefaultWorktreesDir(input.context.$T3_HOME), + }); + return { + preview: resolvedLocation.ok ? resolvedLocation.path : null, + error: resolvedLocation.ok ? null : resolvedLocation.error, + }; +} From ee02eec83fe9b789bef13635aa39b8351342a99b Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sat, 11 Apr 2026 13:20:42 +0300 Subject: [PATCH 2/2] minor ui update --- .../components/settings/SettingsPanels.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fbb2ef772d..88bae139d4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -12,11 +12,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, + WorktreeLocationMode, type ScopedThreadRef, type ProviderKind, type ServerProvider, type ServerProviderModel, - type WorktreeLocationMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -938,7 +938,7 @@ export function GeneralSettingsPanel() { title="Worktree location" description="Controls where T3 Code creates worktrees when New worktree threads and PR worktree preparation let the server choose the path." status={ - worktreeLocationPreview.preview ? ( + worktreeLocation.mode !== "custom" && worktreeLocationPreview.preview ? ( Preview:{" "} @@ -1031,15 +1031,15 @@ export function GeneralSettingsPanel() { ))} -

- {worktreeLocationPreview.error ?? - "Literal substitution only. Include $WORKTREE_NAME in the final path template."} -

+ {worktreeLocation.mode === "custom" && worktreeLocationPreview.preview ? ( +

+ Preview:{" "} + {worktreeLocationPreview.preview} +

+ ) : null} + {worktreeLocationPreview.error ? ( +

{worktreeLocationPreview.error}

+ ) : null} ) : null}