authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-21 18:15:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 18:15:07-07:00
log0141bc2bf5fcf52a563240a6bce4b58050496db5
tree37feeeab81db3b7ebb1d920f39c942aeac5b7c08
parent742173a13250bcba880b700aaead34e1b0810dff
signature Signed by PGP key B5690EEEBB952194

feat(settings): choose how new worktrees initialize submodules (#12955)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

18 files changed, 331 insertions(+), 55 deletions(-)

apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+47-1
......@@ -6,6 +6,7 @@ import {
66 type ServerSettings,
77 type ServerSettingsPatch,
88 type ThreadEnvMode,
9 type WorktreeSubmodules,
910 PROJECT_SCOPED_SERVER_SETTING_KEYS,
1011 type ProjectScopedServerSettingKey,
1112} from "@t3tools/contracts";
......@@ -43,12 +44,32 @@ const PAGE_TITLES: Record<SettingsPage, string> = {
4344};
4445
4546const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
46 "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"],
47 "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
4748 "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"],
4849 "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],
4950 maintenance: ["continueThreadsAfterServerUpdate"],
5051};
5152
53const SUBMODULE_CHOICES: ReadonlyArray<{
54 readonly mode: WorktreeSubmodules | null;
55 readonly label: string;
56 readonly description: string;
57}> = [
58 // Only offered at environment scope; a project falls back through "Use defaults".
59 {
60 mode: null,
61 label: "Inherit",
62 description: "Use the repository's t3.json, or initialize recursively.",
63 },
64 { mode: "recursive", label: "Recursive", description: "Initialize nested submodules too." },
65 {
66 mode: "top-level",
67 label: "Top level only",
68 description: "Skip submodules declared inside other submodules.",
69 },
70 { mode: "none", label: "Skip", description: "Leave submodules empty for a setup script." },
71];
72
5273const WORKSPACE_CHOICES: ReadonlyArray<{
5374 readonly mode: ThreadEnvMode | null;
5475 readonly label: string;
......@@ -251,6 +272,31 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
251272 />
252273 ))}
253274 </SettingsSection>
275 <SettingsSection
276 title="Worktree submodules"
277 trailing={
278 pendingWrites === 0 && isMixed("worktreeSubmodules") ? (
279 <MixedValuesLabel projectSelected={projectSelected} />
280 ) : null
281 }
282 >
283 {SUBMODULE_CHOICES.filter(
284 (choice) => choice.mode !== null || !projectSelected,
285 ).map((choice, index) => (
286 <ChoiceRow
287 key={choice.mode ?? "inherit"}
288 label={choice.label}
289 description={choice.description}
290 selected={
291 !isMixed("worktreeSubmodules") &&
292 uniform("worktreeSubmodules") === choice.mode
293 }
294 separated={index > 0}
295 disabled={disabledFor("worktreeSubmodules")}
296 onPress={() => write({ worktreeSubmodules: choice.mode })}
297 />
298 ))}
299 </SettingsSection>
254300 <SettingsSection
255301 title="Default permissions"
256302 trailing={
apps/server/src/git/GitManager.ts+14-5
......@@ -2543,11 +2543,20 @@ export const make = Effect.gen(function* () {
25432543 });
25442544 }
25452545
2546 const worktree = yield* gitCore.createWorktree({
2547 cwd: input.cwd,
2548 refName: localPullRequestBranch,
2549 path: null,
2550 });
2546 const worktree = yield* gitCore.createWorktree(
2547 {
2548 cwd: input.cwd,
2549 refName: localPullRequestBranch,
2550 path: null,
2551 },
2552 {
2553 // Best effort: a settings read failure falls back to the checkout's t3.json.
2554 submodules: yield* projectSettingsFor(input).pipe(
2555 Effect.map((settings) => settings.worktreeSubmodules),
2556 Effect.orElseSucceed(() => null),
2557 ),
2558 },
2559 );
25512560 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
25522561 yield* maybeRunSetupScript(worktree.worktree.path);
25532562
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+8-5
......@@ -2637,11 +2637,14 @@ describe("ProviderCommandReactor", () => {
26372637
26382638 await waitFor(() => harness.startSession.mock.calls.length === 1);
26392639 expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" });
2640 expect(harness.createWorktree).toHaveBeenCalledWith({
2641 cwd: "/tmp/provider-project",
2642 refName: "feature/restore",
2643 path: worktreePath,
2644 });
2640 expect(harness.createWorktree).toHaveBeenCalledWith(
2641 {
2642 cwd: "/tmp/provider-project",
2643 refName: "feature/restore",
2644 path: worktreePath,
2645 },
2646 { submodules: null },
2647 );
26452648 expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan(
26462649 harness.startSession.mock.invocationCallOrder[0]!,
26472650 );
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+9-1
......@@ -501,8 +501,16 @@ const make = Effect.gen(function* () {
501501 });
502502 // A directory deleted without `git worktree remove` leaves an admin entry
503503 // that makes `git worktree add` refuse the path; prune clears it.
504 // Best effort like the rest of this recovery: a settings read failure
505 // falls back to the checkout's t3.json.
506 const submodules = yield* projectSettingsForThread(thread.id).pipe(
507 Effect.map((settings) => settings.worktreeSubmodules),
508 Effect.orElseSucceed(() => null),
509 );
504510 yield* gitWorkflow.pruneWorktrees({ cwd }).pipe(
505 Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })),
511 Effect.andThen(
512 gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }),
513 ),
506514 Effect.catchCause((cause) =>
507515 Cause.hasInterruptsOnly(cause)
508516 ? Effect.failCause(cause)
apps/server/src/vcs/GitVcsDriver.ts+11-2
......@@ -30,6 +30,7 @@ import {
3030 type VcsRemoveWorktreeInput,
3131 type VcsStatusInput,
3232 type VcsStatusResult,
33 type WorktreeSubmodules,
3334} from "@t3tools/contracts";
3435import {
3536 makeGitVcsDriverCore,
......@@ -128,8 +129,10 @@ export interface CreateWorktreeProgress {
128129 total: number;
129130 }) => Effect.Effect<void, never>;
130131 readonly onSubmodulesStarted?: () => Effect.Effect<void, never>;
131 /** Fires when `.gitmodules` exists but t3.json sets `worktreeSubmodules` to `"none"`. */
132 readonly onSubmodulesDisabled?: () => Effect.Effect<void, never>;
132 /** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */
133 readonly onSubmodulesDisabled?: (input: {
134 source: "settings" | "t3.json";
135 }) => Effect.Effect<void, never>;
133136 readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>;
134137 readonly onSubmodulesFinished?: (input: {
135138 ok: boolean;
......@@ -139,6 +142,12 @@ export interface CreateWorktreeProgress {
139142
140143export interface CreateWorktreeOptions {
141144 readonly progress?: CreateWorktreeProgress;
145 /**
146 * The project-over-environment `worktreeSubmodules` setting. Null (or
147 * omitted, for callers without settings access) defers to the checkout's
148 * own t3.json.
149 */
150 readonly submodules?: WorktreeSubmodules | null;
142151}
143152
144153export interface GitCommitProgress {
apps/server/src/vcs/GitVcsDriverCore.test.ts+23-6
......@@ -26,6 +26,7 @@ import {
2626 GitCommandError,
2727 ReviewDiffPreviewInput,
2828 type ReviewDiffFileContentsInput,
29 type WorktreeSubmodules,
2930} from "@t3tools/contracts";
3031import { ServerConfig } from "../config.ts";
3132import { gitCommandDuration } from "../observability/Metrics.ts";
......@@ -2353,7 +2354,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
23532354 }),
23542355 );
23552356
2356 it.effect("honors the t3.json worktreeSubmodules setting", () =>
2357 it.effect("resolves the submodule mode from the option, then t3.json", () =>
23572358 Effect.gen(function* () {
23582359 const fileSystem = yield* FileSystem.FileSystem;
23592360 const pathService = yield* Path.Path;
......@@ -2392,17 +2393,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
23922393 const worktreesDir = yield* makeTmpDir("git-worktrees-");
23932394
23942395 const createWithMode = Effect.fn(function* (
2395 fileMode: "recursive" | "top-level" | "none",
2396 fileMode: WorktreeSubmodules,
23962397 branch: string,
2398 submodules: WorktreeSubmodules | null = null,
23972399 ) {
23982400 yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`);
23992401 yield* git(cwd, ["add", "t3.json"]);
2400 yield* git(cwd, ["commit", "-m", `submodules: ${fileMode}`]);
2402 // Consecutive cases may reuse a file mode to test the option alone.
2403 yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]);
24012404 const worktreePath = pathService.join(worktreesDir, branch);
2402 const disabled = yield* Ref.make(false);
2405 const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false);
24032406 yield* driver.createWorktree(
24042407 { cwd, path: worktreePath, refName: initialBranch, newRefName: branch },
2405 { progress: { onSubmodulesDisabled: () => Ref.set(disabled, true) } },
2408 {
2409 submodules,
2410 progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) },
2411 },
24062412 );
24072413 return {
24082414 disabled: yield* Ref.get(disabled),
......@@ -2423,8 +2429,19 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
24232429 inner: true,
24242430 nested: false,
24252431 });
2432 // A resolved setting outranks the file in both directions.
2433 assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), {
2434 disabled: "settings",
2435 inner: false,
2436 nested: false,
2437 });
2438 assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), {
2439 disabled: false,
2440 inner: true,
2441 nested: false,
2442 });
24262443 assert.deepEqual(yield* createWithMode("none", "none"), {
2427 disabled: true,
2444 disabled: "t3.json",
24282445 inner: false,
24292446 nested: false,
24302447 });
apps/server/src/vcs/GitVcsDriverCore.ts+28-19
......@@ -32,6 +32,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3232import { compactTraceAttributes } from "@t3tools/shared/observability";
3333import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3434import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
35import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
3536import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
3637import * as GitVcsDriver from "./GitVcsDriver.ts";
3738import {
......@@ -3095,28 +3096,36 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
30953096 // them. Best-effort: the objects are usually already in the parent's
30963097 // `.git/modules`, but a first-ever clone needs the network, and failing to
30973098 // populate a submodule must not roll back the caller's thread. Repos with
3098 // hundreds of nested submodules opt out or stop at the top level through
3099 // t3.json, read from the checkout that was just created.
3099 // hundreds of nested submodules opt out or stop at the top level; the
3100 // caller resolves that from settings, or the checkout's t3.json decides.
31003101 const hasSubmodules = yield* fileSystem
31013102 .exists(path.join(worktreePath, ".gitmodules"))
31023103 .pipe(Effect.orElseSucceed(() => false));
3103 const submoduleMode = hasSubmodules
3104 ? yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe(
3105 Effect.flatMap((contents) => {
3106 const file = parseT3ProjectFile(contents);
3107 return file === null
3108 ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", {
3109 worktreePath,
3110 }).pipe(Effect.as("recursive" as const))
3111 : Effect.succeed(file.worktreeSubmodules ?? "recursive");
3112 }),
3113 Effect.orElseSucceed(() => "recursive" as const),
3114 )
3115 : "none";
3116 if (hasSubmodules && submoduleMode === "none" && progress?.onSubmodulesDisabled) {
3117 yield* progress.onSubmodulesDisabled();
3104 const submoduleMode = !hasSubmodules
3105 ? { value: "none" as const, source: "environment" as const }
3106 : resolveProjectFileBackedSetting(
3107 "worktreeSubmodules",
3108 options?.submodules ?? null,
3109 options?.submodules != null
3110 ? null
3111 : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe(
3112 Effect.flatMap((contents) => {
3113 const file = parseT3ProjectFile(contents);
3114 return file === null
3115 ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", {
3116 worktreePath,
3117 }).pipe(Effect.as(null))
3118 : Effect.succeed(file);
3119 }),
3120 Effect.orElseSucceed(() => null),
3121 ),
3122 );
3123 if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) {
3124 yield* progress.onSubmodulesDisabled({
3125 source: submoduleMode.source === "t3.json" ? "t3.json" : "settings",
3126 });
31183127 }
3119 if (submoduleMode !== "none") {
3128 if (submoduleMode.value !== "none") {
31203129 if (progress?.onSubmodulesStarted) {
31213130 yield* progress.onSubmodulesStarted();
31223131 }
......@@ -3124,7 +3133,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
31243133 yield* runGit(
31253134 "GitVcsDriver.createWorktree.updateSubmodules",
31263135 worktreePath,
3127 submoduleMode === "recursive"
3136 submoduleMode.value === "recursive"
31283137 ? ["submodule", "update", "--init", "--recursive"]
31293138 : ["submodule", "update", "--init"],
31303139 onSubmoduleLine
apps/server/src/ws.ts+37-2
......@@ -81,6 +81,7 @@ import {
8181 type WorktreeSetupSnapshot,
8282} from "@t3tools/contracts";
8383import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
84import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
8485import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
8586import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
8687
......@@ -1044,6 +1045,35 @@ const makeWsRpcLayer = (
10441045 return output;
10451046 });
10461047
1048 // Project setting > environment setting; null when neither is set so
1049 // the driver reads the freshly created checkout's own t3.json (the
1050 // branch being checked out may declare something the project root does
1051 // not). Settings that fail to load fall through the same way.
1052 const resolveBootstrapWorktreeSubmodules = Effect.fnUntraced(function* (input: {
1053 readonly threadId: ThreadId;
1054 readonly projectId: ProjectId | null;
1055 }) {
1056 const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null));
1057 if (!settings) return null;
1058 // A worktree can also be prepared for an existing thread, whose
1059 // project is only known through its shell.
1060 const resolvedProjectId =
1061 input.projectId ??
1062 (yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe(
1063 Effect.map((thread) => Option.getOrNull(thread)?.projectId ?? null),
1064 Effect.orElseSucceed(() => null),
1065 ));
1066 const project =
1067 resolvedProjectId === null
1068 ? null
1069 : yield* projectionSnapshotQuery.getProjectShellById(resolvedProjectId).pipe(
1070 Effect.map(Option.getOrNull),
1071 Effect.orElseSucceed(() => null),
1072 );
1073 return resolveProjectSettings(settings, resolvedProjectId, project).settings
1074 .worktreeSubmodules;
1075 });
1076
10471077 const dispatchBootstrapTurnStart = (
10481078 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
10491079 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
......@@ -1462,6 +1492,10 @@ const makeWsRpcLayer = (
14621492 }
14631493 yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running");
14641494 let checkoutTotal: number | null = null;
1495 const submodules = yield* resolveBootstrapWorktreeSubmodules({
1496 threadId,
1497 projectId: targetProjectId ?? null,
1498 });
14651499 const worktree = yield* gitWorkflow.createWorktree(
14661500 {
14671501 cwd: prepareWorktree.projectCwd,
......@@ -1471,6 +1505,7 @@ const makeWsRpcLayer = (
14711505 path: null,
14721506 },
14731507 {
1508 submodules,
14741509 progress: {
14751510 // Git has registered the directory at this point, so a
14761511 // cancel during the submodule step can still remove it.
......@@ -1500,12 +1535,12 @@ const makeWsRpcLayer = (
15001535 worktreeSetupTracker.stageStatus(threadId, "submodules", "running"),
15011536 ),
15021537 ),
1503 onSubmodulesDisabled: () =>
1538 onSubmodulesDisabled: ({ source }) =>
15041539 worktreeSetupTracker.stageStatus(
15051540 threadId,
15061541 "submodules",
15071542 "skipped",
1508 "disabled in t3.json",
1543 `disabled in ${source}`,
15091544 ),
15101545 onSubmoduleLine: (line) => {
15111546 const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1];
apps/web/src/components/BranchToolbar.logic.ts+13-1
......@@ -1,4 +1,10 @@
1import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts";
1import type {
2 EnvironmentId,
3 EnvironmentMachineKind,
4 VcsRef,
5 ProjectId,
6 WorktreeSubmodules,
7} from "@t3tools/contracts";
28import * as Schema from "effect/Schema";
39import { toSortableTimestamp } from "../lib/threadSort";
410export {
......@@ -86,6 +92,12 @@ export function resolveEnvModeLabel(mode: EnvMode): string {
8692 return mode === "worktree" ? "New worktree" : "Current checkout";
8793}
8894
95export const WORKTREE_SUBMODULES_LABELS: Record<WorktreeSubmodules, string> = {
96 recursive: "Recursive",
97 "top-level": "Top level only",
98 none: "Skip",
99};
100
89101export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {
90102 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
91103}
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+54-1
......@@ -2,6 +2,7 @@ import {
22 DEFAULT_SERVER_SETTINGS,
33 type ModelSelection,
44 type ProviderInstanceId,
5 type WorktreeSubmodules,
56} from "@t3tools/contracts";
67import { createModelSelection } from "@t3tools/shared/model";
78import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
......@@ -16,7 +17,7 @@ import {
1617} from "../../providerInstances";
1718import { useEnvironments } from "../../state/environments";
1819import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
19import { resolveEnvModeLabel } from "../BranchToolbar.logic";
20import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic";
2021import { ProviderModelPicker } from "../chat/ProviderModelPicker";
2122import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig";
2223import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
......@@ -45,6 +46,11 @@ import {
4546 * environment defaults at an environment scope and project overrides at a
4647 * project or checkout scope; the scoped hooks route the write.
4748 */
49const WORKTREE_SUBMODULES_OPTIONS = ["recursive", "top-level", "none"] as const;
50function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules {
51 return value !== null && (WORKTREE_SUBMODULES_OPTIONS as readonly string[]).includes(value);
52}
53
4854export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) {
4955 const { scope, target, targets, connectedEnvironments } = useSettingsScope();
5056 const settings = useScopedSettings();
......@@ -70,6 +76,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
7076 const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]);
7177 const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon;
7278 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
79 const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);
7380 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
7481 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
7582 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
......@@ -319,6 +326,52 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
319326 </Select>
320327 }
321328 />
329 <SettingsRow
330 serverScoped
331 settingKeys={["worktreeSubmodules"]}
332 mixed={mixedSubmodules}
333 {...searchableSetting("worktree-submodules")}
334 description={
335 isProjectScope
336 ? "How new worktrees in this project populate git submodules."
337 : "How new worktrees populate git submodules. Projects and their t3.json can override it."
338 }
339 resetAction={
340 !isProjectScope && settings.worktreeSubmodules !== null ? (
341 <SettingResetButton
342 label="worktree submodules"
343 onClick={() => updateSettings({ worktreeSubmodules: null })}
344 />
345 ) : null
346 }
347 control={
348 <Select
349 value={mixedSubmodules ? null : (effective?.worktreeSubmodules ?? null)}
350 onValueChange={(value) => {
351 if (isWorktreeSubmodules(value)) updateSettings({ worktreeSubmodules: value });
352 }}
353 >
354 <SelectTrigger size="sm" aria-label="Worktree submodules">
355 <SelectValue>
356 {(value: string | null) =>
357 isWorktreeSubmodules(value)
358 ? WORKTREE_SUBMODULES_LABELS[value]
359 : unavailable
360 ? "Unavailable"
361 : "Mixed"
362 }
363 </SelectValue>
364 </SelectTrigger>
365 <SelectPopup align="end" alignItemWithTrigger={false}>
366 {WORKTREE_SUBMODULES_OPTIONS.map((option) => (
367 <SelectItem key={option} value={option}>
368 {WORKTREE_SUBMODULES_LABELS[option]}
369 </SelectItem>
370 ))}
371 </SelectPopup>
372 </Select>
373 }
374 />
322375 </>
323376 ) : category === "source-control" ? (
324377 <>
apps/web/src/components/settings/SettingInheritance.tsx+6-2
......@@ -4,6 +4,7 @@ import {
44 type ProjectFileBackedSettingKey,
55 resolveEnvironmentMachineKind,
66 type ServerSettings,
7 type WorktreeSubmodules,
78} from "@t3tools/contracts";
89import { CheckIcon, LayersIcon } from "lucide-react";
910import * as Equal from "effect/Equal";
......@@ -11,7 +12,7 @@ import * as Equal from "effect/Equal";
1112import { cn } from "../../lib/utils";
1213import type { EnvironmentPresentation } from "../../state/environments";
1314import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
14import { resolveEnvModeLabel } from "../BranchToolbar.logic";
15import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic";
1516import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
1617import { Button, InlineButton } from "../ui/button";
1718import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
......@@ -44,7 +45,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
4445 ? "Automatic"
4546 : key === "sourceControlWriterModelSelection"
4647 ? "Text generation model"
47 : key === "defaultThreadEnvMode"
48 : key === "defaultThreadEnvMode" || key === "worktreeSubmodules"
4849 ? "Inherit"
4950 : "Not set";
5051 }
......@@ -58,6 +59,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
5859 if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) {
5960 return resolveEnvModeLabel(value);
6061 }
62 if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) {
63 return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules];
64 }
6165 if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {
6266 return PULL_REQUEST_MERGE_METHOD_LABELS[
6367 value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS
apps/web/src/components/settings/settingsSearch.ts+7
......@@ -401,6 +401,13 @@ export const SETTINGS_SEARCH_ITEMS = [
401401 scope: "project-defaults",
402402 searchTerms: ["default workspace mode draft local worktree"],
403403 },
404 {
405 id: "worktree-submodules",
406 title: "Submodules",
407 to: "/settings/general",
408 scope: "project-defaults",
409 searchTerms: ["git submodule init recursive top-level none worktree t3.json"],
410 },
404411 {
405412 id: "start-from-origin",
406413 title: "Start from origin",
docs/user/project-settings.md+5-2
......@@ -55,8 +55,11 @@ built-in default. Leave a setting on **Inherit** to let the next tier decide.
5555Browser access changes apply when an agent session next starts.
5656
5757New worktrees initialize git submodules recursively. If that step is slow because the repository
58declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop
59at the ones the repository declares itself, or `"none"` to leave them for a setup script.
58declares many nested submodules, set **Submodules** in **Settings → General** (with the project
59selected to override it there) to **Top level only** to stop at the ones the repository declares
60itself, or **Skip** to leave them for a setup script. It resolves in the same order as the
61workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out
62applies when the project and environment are both on **Inherit**.
6063
6164## Storage cleanup
6265
packages/contracts/src/settings.test.ts+9
......@@ -835,6 +835,15 @@ describe("ServerSettings worktree defaults", () => {
835835 decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin,
836836 ).toBe(false);
837837 });
838
839 it("defaults worktree submodules to inherit and tolerates unknown modes", () => {
840 expect(decodeServerSettings({}).worktreeSubmodules).toBeNull();
841 expect(decodeServerSettings({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe(
842 "top-level",
843 );
844 expect(decodeServerSettings({ worktreeSubmodules: "shallow" }).worktreeSubmodules).toBeNull();
845 expect(decodeServerSettingsPatch({ worktreeSubmodules: null }).worktreeSubmodules).toBeNull();
846 });
838847});
839848
840849describe("ServerSettings.sourceControlWritingStyle", () => {
packages/contracts/src/settings.ts+13-1
......@@ -5,13 +5,14 @@ import * as Schema from "effect/Schema";
55import * as SchemaTransformation from "effect/SchemaTransformation";
66import {
77 ForwardCompatibleNullable,
8 ForwardCompatibleOptional,
89 OmittedWhenNull,
910 ProjectId,
1011 TrimmedNonEmptyString,
1112 TrimmedString,
1213} from "./baseSchemas.ts";
1314import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
14import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
15import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";
1516import { KeybindingShortcut } from "./keybindings.ts";
1617import {
1718 CustomModelSetting,
......@@ -1001,6 +1002,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
10011002 "defaultRuntimeMode",
10021003 "defaultThreadEnvMode",
10031004 "newWorktreesStartFromOrigin",
1005 "worktreeSubmodules",
10041006 "defaultAutoPull",
10051007 "defaultProjectScripts",
10061008 "enableAgentBrowserAccess",
......@@ -1027,6 +1029,7 @@ export const ProjectSettingsOverrides = Schema.Struct({
10271029 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
10281030 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
10291031 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
1032 worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules),
10301033 defaultAutoPull: Schema.optionalKey(Schema.Boolean),
10311034 defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),
10321035 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
......@@ -1208,6 +1211,14 @@ export const ServerSettings = Schema.Struct({
12081211 newWorktreesStartFromOrigin: Schema.Boolean.pipe(
12091212 Schema.withDecodingDefault(Effect.succeed(true)),
12101213 ),
1214 /**
1215 * Null defers to the repository's t3.json, then to recursive. A value
1216 * picked on a newer server decodes as null here rather than failing the
1217 * whole settings snapshot for an older client.
1218 */
1219 worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe(
1220 Schema.withDecodingDefault(Effect.succeed(null)),
1221 ),
12111222 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
12121223 textGenerationModelSelection: ModelSelection.pipe(
12131224 Schema.withDecodingDefault(
......@@ -1499,6 +1510,7 @@ export const ServerSettingsPatch = Schema.Struct({
14991510 environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)),
15001511 defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)),
15011512 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
1513 worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)),
15021514 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
15031515 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
15041516 sourceControlWritingStyle: Schema.optionalKey(
packages/contracts/src/t3ProjectFile.ts+2-1
......@@ -90,7 +90,7 @@ export const T3ProjectFile = Schema.Struct({
9090 worktreeSubmodules: Schema.optionalKey(
9191 WorktreeSubmodules.annotate({
9292 description:
93 '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.',
93 '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.',
9494 }),
9595 ),
9696 scripts: Schema.optionalKey(
......@@ -118,6 +118,7 @@ export type T3ProjectFile = typeof T3ProjectFile.Type;
118118 */
119119export const PROJECT_FILE_BACKED_SETTINGS = {
120120 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },
121 worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" },
121122} as const satisfies {
122123 readonly [K in ProjectScopedServerSettingKey]?: {
123124 readonly field: {
packages/shared/src/projectSettings.test.ts+18
......@@ -9,6 +9,7 @@ import { createModelSelection } from "./model.ts";
99import {
1010 clearProjectSettingsOverrides,
1111 hasProjectSettingsOverrides,
12 resolveProjectFileBackedSetting,
1213 resolveProjectSettings,
1314 resolveWorktreeCleanup,
1415 withProjectSettingsOverrides,
......@@ -187,6 +188,23 @@ describe("resolveProjectSettings with a t3.json", () => {
187188 ).toBe("environment");
188189 });
189190
191 it("resolves one key from the settings tier, then the file, then the built-in", () => {
192 expect(
193 resolveProjectFileBackedSetting("worktreeSubmodules", "none", {
194 worktreeSubmodules: "top-level",
195 }),
196 ).toEqual({ value: "none", source: "environment" });
197 expect(
198 resolveProjectFileBackedSetting("worktreeSubmodules", null, {
199 worktreeSubmodules: "top-level",
200 }),
201 ).toEqual({ value: "top-level", source: "t3.json" });
202 expect(resolveProjectFileBackedSetting("worktreeSubmodules", null, null)).toEqual({
203 value: "recursive",
204 source: "environment",
205 });
206 });
207
190208 it("leaves settings untouched when no file is passed", () => {
191209 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe(
192210 DEFAULT_SERVER_SETTINGS,
packages/shared/src/projectSettings.ts+27-6
......@@ -2,6 +2,7 @@ import {
22 type ModelSelection,
33 PROJECT_FILE_BACKED_SETTINGS,
44 PROJECT_SCOPED_SERVER_SETTING_KEYS,
5 type ProjectFileBackedSettingKey,
56 type ProjectId,
67 type ProjectScopedServerSettingKey,
78 type ProjectSettingsOverrides,
......@@ -104,22 +105,42 @@ function applyProjectFile(
104105): ResolvedProjectSettings {
105106 let effective: Record<string, unknown> | null = null;
106107 let sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> | null = null;
107 for (const [key, { field, builtIn }] of Object.entries(PROJECT_FILE_BACKED_SETTINGS)) {
108 const settingKey = key as ProjectScopedServerSettingKey;
109 if (resolved.settings[settingKey] !== null) continue;
110 const fromFile = projectFile?.[field];
108 for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) {
109 if (resolved.settings[key] !== null) continue;
110 const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile);
111111 effective ??= { ...resolved.settings };
112112 sources ??= { ...resolved.sources };
113 effective[settingKey] = fromFile ?? builtIn;
113 effective[key] = value;
114114 // A project override of null defers like an unset one, so the value did
115115 // not come from the project either way.
116 sources[settingKey] = fromFile === undefined ? "environment" : "t3.json";
116 sources[key] = source;
117117 }
118118 return effective === null || sources === null
119119 ? resolved
120120 : { ...resolved, settings: effective as ServerSettings, sources };
121121}
122122
123/**
124 * The file and built-in tiers for one key, given the project-over-environment
125 * value (`null` when neither is set). For callers that hold the settings tier
126 * but only see the file later, such as the git driver reading the t3.json of
127 * the checkout it just created. Same chain as `resolveProjectSettings`.
128 */
129export function resolveProjectFileBackedSetting<K extends ProjectFileBackedSettingKey>(
130 key: K,
131 setting: ServerSettings[K],
132 projectFile: T3ProjectFile | null,
133): { value: ResolvedServerSettings[K]; source: ProjectSettingSource } {
134 if (setting !== null) {
135 return { value: setting as ResolvedServerSettings[K], source: "environment" };
136 }
137 const { field, builtIn } = PROJECT_FILE_BACKED_SETTINGS[key];
138 const fromFile = projectFile?.[field] as ResolvedServerSettings[K] | undefined;
139 return fromFile === undefined
140 ? { value: builtIn as ResolvedServerSettings[K], source: "environment" }
141 : { value: fromFile, source: "t3.json" };
142}
143
123144function resolveProjectOverrides(
124145 settings: ServerSettings,
125146 projectId: ProjectId | null,