From 0141bc2bf5fcf52a563240a6bce4b58050496db5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 18:15:07 -0700 Subject: [PATCH] feat(settings): choose how new worktrees initialize submodules (#12955) Co-authored-by: Claude Fable 5 --- .../SettingsServerControlsRouteScreen.tsx | 48 +++++++++++++++- apps/server/src/git/GitManager.ts | 19 +++++-- .../Layers/ProviderCommandReactor.test.ts | 13 +++-- .../Layers/ProviderCommandReactor.ts | 10 +++- apps/server/src/vcs/GitVcsDriver.ts | 13 ++++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 29 ++++++++-- apps/server/src/vcs/GitVcsDriverCore.ts | 47 +++++++++------- apps/server/src/ws.ts | 39 ++++++++++++- .../web/src/components/BranchToolbar.logic.ts | 14 ++++- .../settings/ProjectDefaultsSettings.tsx | 55 ++++++++++++++++++- .../settings/SettingInheritance.tsx | 8 ++- .../src/components/settings/settingsSearch.ts | 7 +++ docs/user/project-settings.md | 7 ++- packages/contracts/src/settings.test.ts | 9 +++ packages/contracts/src/settings.ts | 14 ++++- packages/contracts/src/t3ProjectFile.ts | 3 +- packages/shared/src/projectSettings.test.ts | 18 ++++++ packages/shared/src/projectSettings.ts | 33 +++++++++-- 18 files changed, 331 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 94312207714ad2a4b17d69282ce2028876bc123b..8aff022910d082c581640bcdd7c5e00aa68abcee 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -6,6 +6,7 @@ import { type ServerSettings, type ServerSettingsPatch, type ThreadEnvMode, + type WorktreeSubmodules, PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectScopedServerSettingKey, } from "@t3tools/contracts"; @@ -43,12 +44,32 @@ const PAGE_TITLES: Record = { }; const PAGE_PROJECT_KEYS: Record = { - "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"], + "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; +const SUBMODULE_CHOICES: ReadonlyArray<{ + readonly mode: WorktreeSubmodules | null; + readonly label: string; + readonly description: string; +}> = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or initialize recursively.", + }, + { mode: "recursive", label: "Recursive", description: "Initialize nested submodules too." }, + { + mode: "top-level", + label: "Top level only", + description: "Skip submodules declared inside other submodules.", + }, + { mode: "none", label: "Skip", description: "Leave submodules empty for a setup script." }, +]; + const WORKSPACE_CHOICES: ReadonlyArray<{ readonly mode: ThreadEnvMode | null; readonly label: string; @@ -251,6 +272,31 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { /> ))} + + ) : null + } + > + {SUBMODULE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( + 0} + disabled={disabledFor("worktreeSubmodules")} + onPress={() => write({ worktreeSubmodules: choice.mode })} + /> + ))} + settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ), + }, + ); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9bc701af0837524f480c1298f2acb8c29ec38779..c7d9417bc75bdcd5ee863e329cd1eb4e9ff3168f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2637,11 +2637,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-project", - refName: "feature/restore", - path: worktreePath, - }); + expect(harness.createWorktree).toHaveBeenCalledWith( + { + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }, + { submodules: null }, + ); expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( harness.startSession.mock.invocationCallOrder[0]!, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6a42b7c67ae10e77ed7993f0f124daaa6e9189f4..bdf4fe8e69d990c73cd883e6e687180a30f1fff5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -501,8 +501,16 @@ const make = Effect.gen(function* () { }); // A directory deleted without `git worktree remove` leaves an admin entry // that makes `git worktree add` refuse the path; prune clears it. + // Best effort like the rest of this recovery: a settings read failure + // falls back to the checkout's t3.json. + const submodules = yield* projectSettingsForThread(thread.id).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ); yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.andThen( + gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), + ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 040862eaf6a8abf02d27f5809a6a3792e2a65ffa..f450eef0dc248bc825a6a37f2344d4ec6b98283c 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,6 +30,7 @@ import { type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { makeGitVcsDriverCore, @@ -128,8 +129,10 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; - /** Fires when `.gitmodules` exists but t3.json sets `worktreeSubmodules` to `"none"`. */ - readonly onSubmodulesDisabled?: () => Effect.Effect; + /** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */ + readonly onSubmodulesDisabled?: (input: { + source: "settings" | "t3.json"; + }) => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; @@ -139,6 +142,12 @@ export interface CreateWorktreeProgress { export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; + /** + * The project-over-environment `worktreeSubmodules` setting. Null (or + * omitted, for callers without settings access) defers to the checkout's + * own t3.json. + */ + readonly submodules?: WorktreeSubmodules | null; } export interface GitCommitProgress { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 045f6d15cfe6458a924128bc4ab709fa018b9788..98dec86522de330ecd9d17909d9088060928900a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, type ReviewDiffFileContentsInput, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; @@ -2353,7 +2354,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("honors the t3.json worktreeSubmodules setting", () => + it.effect("resolves the submodule mode from the option, then t3.json", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; @@ -2392,17 +2393,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const worktreesDir = yield* makeTmpDir("git-worktrees-"); const createWithMode = Effect.fn(function* ( - fileMode: "recursive" | "top-level" | "none", + fileMode: WorktreeSubmodules, branch: string, + submodules: WorktreeSubmodules | null = null, ) { yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); yield* git(cwd, ["add", "t3.json"]); - yield* git(cwd, ["commit", "-m", `submodules: ${fileMode}`]); + // Consecutive cases may reuse a file mode to test the option alone. + yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]); const worktreePath = pathService.join(worktreesDir, branch); - const disabled = yield* Ref.make(false); + const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false); yield* driver.createWorktree( { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, - { progress: { onSubmodulesDisabled: () => Ref.set(disabled, true) } }, + { + submodules, + progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) }, + }, ); return { disabled: yield* Ref.get(disabled), @@ -2423,8 +2429,19 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { inner: true, nested: false, }); + // A resolved setting outranks the file in both directions. + assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), { + disabled: "settings", + inner: false, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); assert.deepEqual(yield* createWithMode("none", "none"), { - disabled: true, + disabled: "t3.json", inner: false, nested: false, }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 18587c9f36ef744edbf7b19575a3613382946cfe..9f91264ee6234274418e54f89253e57d4ddf9bd2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -32,6 +32,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -3095,28 +3096,36 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // them. Best-effort: the objects are usually already in the parent's // `.git/modules`, but a first-ever clone needs the network, and failing to // populate a submodule must not roll back the caller's thread. Repos with - // hundreds of nested submodules opt out or stop at the top level through - // t3.json, read from the checkout that was just created. + // hundreds of nested submodules opt out or stop at the top level; the + // caller resolves that from settings, or the checkout's t3.json decides. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - const submoduleMode = hasSubmodules - ? yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( - Effect.flatMap((contents) => { - const file = parseT3ProjectFile(contents); - return file === null - ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { - worktreePath, - }).pipe(Effect.as("recursive" as const)) - : Effect.succeed(file.worktreeSubmodules ?? "recursive"); - }), - Effect.orElseSucceed(() => "recursive" as const), - ) - : "none"; - if (hasSubmodules && submoduleMode === "none" && progress?.onSubmodulesDisabled) { - yield* progress.onSubmodulesDisabled(); + const submoduleMode = !hasSubmodules + ? { value: "none" as const, source: "environment" as const } + : resolveProjectFileBackedSetting( + "worktreeSubmodules", + options?.submodules ?? null, + options?.submodules != null + ? null + : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as(null)) + : Effect.succeed(file); + }), + Effect.orElseSucceed(() => null), + ), + ); + if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled({ + source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", + }); } - if (submoduleMode !== "none") { + if (submoduleMode.value !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3124,7 +3133,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - submoduleMode === "recursive" + submoduleMode.value === "recursive" ? ["submodule", "update", "--init", "--recursive"] : ["submodule", "update", "--init"], onSubmoduleLine diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 909216d89349717a1fa14d6fbe7c12009aa3d55a..652b1ddf3f52d1ba5fcd6d720ca4c38e635976e3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -81,6 +81,7 @@ import { type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -1044,6 +1045,35 @@ const makeWsRpcLayer = ( return output; }); + // Project setting > environment setting; null when neither is set so + // the driver reads the freshly created checkout's own t3.json (the + // branch being checked out may declare something the project root does + // not). Settings that fail to load fall through the same way. + const resolveBootstrapWorktreeSubmodules = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId | null; + }) { + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); + if (!settings) return null; + // A worktree can also be prepared for an existing thread, whose + // project is only known through its shell. + const resolvedProjectId = + input.projectId ?? + (yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.map((thread) => Option.getOrNull(thread)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); + const project = + resolvedProjectId === null + ? null + : yield* projectionSnapshotQuery.getProjectShellById(resolvedProjectId).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + return resolveProjectSettings(settings, resolvedProjectId, project).settings + .worktreeSubmodules; + }); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -1462,6 +1492,10 @@ const makeWsRpcLayer = ( } yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); let checkoutTotal: number | null = null; + const submodules = yield* resolveBootstrapWorktreeSubmodules({ + threadId, + projectId: targetProjectId ?? null, + }); const worktree = yield* gitWorkflow.createWorktree( { cwd: prepareWorktree.projectCwd, @@ -1471,6 +1505,7 @@ const makeWsRpcLayer = ( path: null, }, { + submodules, progress: { // Git has registered the directory at this point, so a // cancel during the submodule step can still remove it. @@ -1500,12 +1535,12 @@ const makeWsRpcLayer = ( worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), - onSubmodulesDisabled: () => + onSubmodulesDisabled: ({ source }) => worktreeSetupTracker.stageStatus( threadId, "submodules", "skipped", - "disabled in t3.json", + `disabled in ${source}`, ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0577f5e8dd1f4e9f6f28350a008a29a7a89927d9..5b046ed6a68628f9a53b39aba8f012a9c073fdd9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { + EnvironmentId, + EnvironmentMachineKind, + VcsRef, + ProjectId, + WorktreeSubmodules, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -86,6 +92,12 @@ export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } +export const WORKTREE_SUBMODULES_LABELS: Record = { + recursive: "Recursive", + "top-level": "Top level only", + none: "Skip", +}; + export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index d49eaa5cbf85d2447ade2e0a8b991ffda178e270..25a3f5d1fa26f1f3ddbc49d87d7f3cdf3464657d 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, type ModelSelection, type ProviderInstanceId, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -16,7 +17,7 @@ import { } from "../../providerInstances"; import { useEnvironments } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; -import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; @@ -45,6 +46,11 @@ import { * environment defaults at an environment scope and project overrides at a * project or checkout scope; the scoped hooks route the write. */ +const WORKTREE_SUBMODULES_OPTIONS = ["recursive", "top-level", "none"] as const; +function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules { + return value !== null && (WORKTREE_SUBMODULES_OPTIONS as readonly string[]).includes(value); +} + export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); @@ -70,6 +76,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); + const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]); const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); @@ -319,6 +326,52 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting } /> + updateSettings({ worktreeSubmodules: null })} + /> + ) : null + } + control={ + + } + /> ) : category === "source-control" ? ( <> diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 623e830561558e81acaf438bd17a1436d527d2fc..a351fa5fa25b30a9edb0da6c964896b8b1824a3a 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -4,6 +4,7 @@ import { type ProjectFileBackedSettingKey, resolveEnvironmentMachineKind, type ServerSettings, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { CheckIcon, LayersIcon } from "lucide-react"; import * as Equal from "effect/Equal"; @@ -11,7 +12,7 @@ import * as Equal from "effect/Equal"; import { cn } from "../../lib/utils"; import type { EnvironmentPresentation } from "../../state/environments"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { Button, InlineButton } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -44,7 +45,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { ? "Automatic" : key === "sourceControlWriterModelSelection" ? "Text generation model" - : key === "defaultThreadEnvMode" + : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" ? "Inherit" : "Not set"; } @@ -58,6 +59,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) { return resolveEnvModeLabel(value); } + if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) { + return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules]; + } if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) { return PULL_REQUEST_MERGE_METHOD_LABELS[ value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 88f0326ea9e0c47970ee30e602250e44baf937cb..acc9b7eb7dbe64316492ca77c17d4e6bebd74d9c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -401,6 +401,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["default workspace mode draft local worktree"], }, + { + id: "worktree-submodules", + title: "Submodules", + to: "/settings/general", + scope: "project-defaults", + searchTerms: ["git submodule init recursive top-level none worktree t3.json"], + }, { id: "start-from-origin", title: "Start from origin", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 363b2a4583a41e734a2286be8fc4bcc43caf796a..b5ad7c6514d19d20f16ade761ccf0c0edc6d33ac 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -55,8 +55,11 @@ built-in default. Leave a setting on **Inherit** to let the next tier decide. Browser access changes apply when an agent session next starts. New worktrees initialize git submodules recursively. If that step is slow because the repository -declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop -at the ones the repository declares itself, or `"none"` to leave them for a setup script. +declares many nested submodules, set **Submodules** in **Settings → General** (with the project +selected to override it there) to **Top level only** to stop at the ones the repository declares +itself, or **Skip** to leave them for a setup script. It resolves in the same order as the +workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out +applies when the project and environment are both on **Inherit**. ## Storage cleanup diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 007d04505c86c3e9012e6f20439e45aff610e5af..692edfb18a658f80ef5d02e73612ca995d85625d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -835,6 +835,15 @@ describe("ServerSettings worktree defaults", () => { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults worktree submodules to inherit and tolerates unknown modes", () => { + expect(decodeServerSettings({}).worktreeSubmodules).toBeNull(); + expect(decodeServerSettings({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe( + "top-level", + ); + expect(decodeServerSettings({ worktreeSubmodules: "shallow" }).worktreeSubmodules).toBeNull(); + expect(decodeServerSettingsPatch({ worktreeSubmodules: null }).worktreeSubmodules).toBeNull(); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4a57f6680fb19f721611d833e15ff3de29e3ce5d..504a9423f2a37040cd30cd5c26b34c6a19c2427a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,13 +5,14 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ForwardCompatibleNullable, + ForwardCompatibleOptional, OmittedWhenNull, ProjectId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; -import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; +import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, @@ -1001,6 +1002,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultRuntimeMode", "defaultThreadEnvMode", "newWorktreesStartFromOrigin", + "worktreeSubmodules", "defaultAutoPull", "defaultProjectScripts", "enableAgentBrowserAccess", @@ -1027,6 +1029,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), @@ -1208,6 +1211,14 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + /** + * Null defers to the repository's t3.json, then to recursive. A value + * picked on a newer server decodes as null here rather than failing the + * whole settings snapshot for an older client. + */ + worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -1499,6 +1510,7 @@ export const ServerSettingsPatch = Schema.Struct({ environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 53294a9759b365a4a978184d77502960d78e7be7..536ac77880aa4c99a8e569e768d47220f0132030 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -90,7 +90,7 @@ export const T3ProjectFile = Schema.Struct({ worktreeSubmodules: Schema.optionalKey( WorktreeSubmodules.annotate({ description: - 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle.', + 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle. A project or environment setting in T3 Code overrides this.', }), ), scripts: Schema.optionalKey( @@ -118,6 +118,7 @@ export type T3ProjectFile = typeof T3ProjectFile.Type; */ export const PROJECT_FILE_BACKED_SETTINGS = { defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, + worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, } as const satisfies { readonly [K in ProjectScopedServerSettingKey]?: { readonly field: { diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index c4c02660a311a23fce1ae8d55a5fc11a08dbbdda..f59156530aa5bc2d39b6c5382696defae1b1d7f4 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -9,6 +9,7 @@ import { createModelSelection } from "./model.ts"; import { clearProjectSettingsOverrides, hasProjectSettingsOverrides, + resolveProjectFileBackedSetting, resolveProjectSettings, resolveWorktreeCleanup, withProjectSettingsOverrides, @@ -187,6 +188,23 @@ describe("resolveProjectSettings with a t3.json", () => { ).toBe("environment"); }); + it("resolves one key from the settings tier, then the file, then the built-in", () => { + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", "none", { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "none", source: "environment" }); + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", null, { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "top-level", source: "t3.json" }); + expect(resolveProjectFileBackedSetting("worktreeSubmodules", null, null)).toEqual({ + value: "recursive", + source: "environment", + }); + }); + it("leaves settings untouched when no file is passed", () => { expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe( DEFAULT_SERVER_SETTINGS, diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 967f3bc6b0c5031d147d502ca5c70ca22bef203c..f99dacbbb69df3814aeeb95e28b88d43b5e1aaba 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -2,6 +2,7 @@ import { type ModelSelection, PROJECT_FILE_BACKED_SETTINGS, PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectFileBackedSettingKey, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, @@ -104,22 +105,42 @@ function applyProjectFile( ): ResolvedProjectSettings { let effective: Record | null = null; let sources: Record | null = null; - for (const [key, { field, builtIn }] of Object.entries(PROJECT_FILE_BACKED_SETTINGS)) { - const settingKey = key as ProjectScopedServerSettingKey; - if (resolved.settings[settingKey] !== null) continue; - const fromFile = projectFile?.[field]; + for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) { + if (resolved.settings[key] !== null) continue; + const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile); effective ??= { ...resolved.settings }; sources ??= { ...resolved.sources }; - effective[settingKey] = fromFile ?? builtIn; + effective[key] = value; // A project override of null defers like an unset one, so the value did // not come from the project either way. - sources[settingKey] = fromFile === undefined ? "environment" : "t3.json"; + sources[key] = source; } return effective === null || sources === null ? resolved : { ...resolved, settings: effective as ServerSettings, sources }; } +/** + * The file and built-in tiers for one key, given the project-over-environment + * value (`null` when neither is set). For callers that hold the settings tier + * but only see the file later, such as the git driver reading the t3.json of + * the checkout it just created. Same chain as `resolveProjectSettings`. + */ +export function resolveProjectFileBackedSetting( + key: K, + setting: ServerSettings[K], + projectFile: T3ProjectFile | null, +): { value: ResolvedServerSettings[K]; source: ProjectSettingSource } { + if (setting !== null) { + return { value: setting as ResolvedServerSettings[K], source: "environment" }; + } + const { field, builtIn } = PROJECT_FILE_BACKED_SETTINGS[key]; + const fromFile = projectFile?.[field] as ResolvedServerSettings[K] | undefined; + return fromFile === undefined + ? { value: builtIn as ResolvedServerSettings[K], source: "environment" } + : { value: fromFile, source: "t3.json" }; +} + function resolveProjectOverrides( settings: ServerSettings, projectId: ProjectId | null, -- 2.54.0