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 {...@@ -6,6 +6,7 @@ import {
6 type ServerSettings,6 type ServerSettings,
7 type ServerSettingsPatch,7 type ServerSettingsPatch,
8 type ThreadEnvMode,8 type ThreadEnvMode,
9 type WorktreeSubmodules,
9 PROJECT_SCOPED_SERVER_SETTING_KEYS,10 PROJECT_SCOPED_SERVER_SETTING_KEYS,
10 type ProjectScopedServerSettingKey,11 type ProjectScopedServerSettingKey,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
...@@ -43,12 +44,32 @@ const PAGE_TITLES: Record<SettingsPage, string> = {...@@ -43,12 +44,32 @@ const PAGE_TITLES: Record<SettingsPage, string> = {
43};44};
4445
45const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {46const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
46 "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"],47 "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
47 "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"],48 "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"],
48 "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],49 "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],
49 maintenance: ["continueThreadsAfterServerUpdate"],50 maintenance: ["continueThreadsAfterServerUpdate"],
50};51};
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
52const WORKSPACE_CHOICES: ReadonlyArray<{73const WORKSPACE_CHOICES: ReadonlyArray<{
53 readonly mode: ThreadEnvMode | null;74 readonly mode: ThreadEnvMode | null;
54 readonly label: string;75 readonly label: string;
...@@ -251,6 +272,31 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -251,6 +272,31 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
251 />272 />
252 ))}273 ))}
253 </SettingsSection>274 </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>
254 <SettingsSection300 <SettingsSection
255 title="Default permissions"301 title="Default permissions"
256 trailing={302 trailing={
apps/server/src/git/GitManager.ts+14-5
...@@ -2543,11 +2543,20 @@ export const make = Effect.gen(function* () {...@@ -2543,11 +2543,20 @@ export const make = Effect.gen(function* () {
2543 });2543 });
2544 }2544 }
25452545
2546 const worktree = yield* gitCore.createWorktree({2546 const worktree = yield* gitCore.createWorktree(
2547 cwd: input.cwd,2547 {
2548 refName: localPullRequestBranch,2548 cwd: input.cwd,
2549 path: null,2549 refName: localPullRequestBranch,
2550 });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 );
2551 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);2560 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
2552 yield* maybeRunSetupScript(worktree.worktree.path);2561 yield* maybeRunSetupScript(worktree.worktree.path);
25532562
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+8-5
...@@ -2637,11 +2637,14 @@ describe("ProviderCommandReactor", () => {...@@ -2637,11 +2637,14 @@ describe("ProviderCommandReactor", () => {
26372637
2638 await waitFor(() => harness.startSession.mock.calls.length === 1);2638 await waitFor(() => harness.startSession.mock.calls.length === 1);
2639 expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" });2639 expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" });
2640 expect(harness.createWorktree).toHaveBeenCalledWith({2640 expect(harness.createWorktree).toHaveBeenCalledWith(
2641 cwd: "/tmp/provider-project",2641 {
2642 refName: "feature/restore",2642 cwd: "/tmp/provider-project",
2643 path: worktreePath,2643 refName: "feature/restore",
2644 });2644 path: worktreePath,
2645 },
2646 { submodules: null },
2647 );
2645 expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan(2648 expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan(
2646 harness.startSession.mock.invocationCallOrder[0]!,2649 harness.startSession.mock.invocationCallOrder[0]!,
2647 );2650 );
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+9-1
...@@ -501,8 +501,16 @@ const make = Effect.gen(function* () {...@@ -501,8 +501,16 @@ const make = Effect.gen(function* () {
501 });501 });
502 // A directory deleted without `git worktree remove` leaves an admin entry502 // A directory deleted without `git worktree remove` leaves an admin entry
503 // that makes `git worktree add` refuse the path; prune clears it.503 // 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 );
504 yield* gitWorkflow.pruneWorktrees({ cwd }).pipe(510 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 ),
506 Effect.catchCause((cause) =>514 Effect.catchCause((cause) =>
507 Cause.hasInterruptsOnly(cause)515 Cause.hasInterruptsOnly(cause)
508 ? Effect.failCause(cause)516 ? Effect.failCause(cause)
apps/server/src/vcs/GitVcsDriver.ts+11-2
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30 type VcsRemoveWorktreeInput,30 type VcsRemoveWorktreeInput,
31 type VcsStatusInput,31 type VcsStatusInput,
32 type VcsStatusResult,32 type VcsStatusResult,
33 type WorktreeSubmodules,
33} from "@t3tools/contracts";34} from "@t3tools/contracts";
34import {35import {
35 makeGitVcsDriverCore,36 makeGitVcsDriverCore,
...@@ -128,8 +129,10 @@ export interface CreateWorktreeProgress {...@@ -128,8 +129,10 @@ export interface CreateWorktreeProgress {
128 total: number;129 total: number;
129 }) => Effect.Effect<void, never>;130 }) => Effect.Effect<void, never>;
130 readonly onSubmodulesStarted?: () => Effect.Effect<void, never>;131 readonly onSubmodulesStarted?: () => Effect.Effect<void, never>;
131 /** Fires when `.gitmodules` exists but t3.json sets `worktreeSubmodules` to `"none"`. */132 /** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */
132 readonly onSubmodulesDisabled?: () => Effect.Effect<void, never>;133 readonly onSubmodulesDisabled?: (input: {
134 source: "settings" | "t3.json";
135 }) => Effect.Effect<void, never>;
133 readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>;136 readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>;
134 readonly onSubmodulesFinished?: (input: {137 readonly onSubmodulesFinished?: (input: {
135 ok: boolean;138 ok: boolean;
...@@ -139,6 +142,12 @@ export interface CreateWorktreeProgress {...@@ -139,6 +142,12 @@ export interface CreateWorktreeProgress {
139142
140export interface CreateWorktreeOptions {143export interface CreateWorktreeOptions {
141 readonly progress?: CreateWorktreeProgress;144 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;
142}151}
143152
144export interface GitCommitProgress {153export interface GitCommitProgress {
apps/server/src/vcs/GitVcsDriverCore.test.ts+23-6
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 GitCommandError,26 GitCommandError,
27 ReviewDiffPreviewInput,27 ReviewDiffPreviewInput,
28 type ReviewDiffFileContentsInput,28 type ReviewDiffFileContentsInput,
29 type WorktreeSubmodules,
29} from "@t3tools/contracts";30} from "@t3tools/contracts";
30import { ServerConfig } from "../config.ts";31import { ServerConfig } from "../config.ts";
31import { gitCommandDuration } from "../observability/Metrics.ts";32import { gitCommandDuration } from "../observability/Metrics.ts";
...@@ -2353,7 +2354,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -2353,7 +2354,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
2353 }),2354 }),
2354 );2355 );
23552356
2356 it.effect("honors the t3.json worktreeSubmodules setting", () =>2357 it.effect("resolves the submodule mode from the option, then t3.json", () =>
2357 Effect.gen(function* () {2358 Effect.gen(function* () {
2358 const fileSystem = yield* FileSystem.FileSystem;2359 const fileSystem = yield* FileSystem.FileSystem;
2359 const pathService = yield* Path.Path;2360 const pathService = yield* Path.Path;
...@@ -2392,17 +2393,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -2392,17 +2393,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
2392 const worktreesDir = yield* makeTmpDir("git-worktrees-");2393 const worktreesDir = yield* makeTmpDir("git-worktrees-");
23932394
2394 const createWithMode = Effect.fn(function* (2395 const createWithMode = Effect.fn(function* (
2395 fileMode: "recursive" | "top-level" | "none",2396 fileMode: WorktreeSubmodules,
2396 branch: string,2397 branch: string,
2398 submodules: WorktreeSubmodules | null = null,
2397 ) {2399 ) {
2398 yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`);2400 yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`);
2399 yield* git(cwd, ["add", "t3.json"]);2401 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}`]);
2401 const worktreePath = pathService.join(worktreesDir, branch);2404 const worktreePath = pathService.join(worktreesDir, branch);
2402 const disabled = yield* Ref.make(false);2405 const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false);
2403 yield* driver.createWorktree(2406 yield* driver.createWorktree(
2404 { cwd, path: worktreePath, refName: initialBranch, newRefName: branch },2407 { 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 },
2406 );2412 );
2407 return {2413 return {
2408 disabled: yield* Ref.get(disabled),2414 disabled: yield* Ref.get(disabled),
...@@ -2423,8 +2429,19 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -2423,8 +2429,19 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
2423 inner: true,2429 inner: true,
2424 nested: false,2430 nested: false,
2425 });2431 });
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 });
2426 assert.deepEqual(yield* createWithMode("none", "none"), {2443 assert.deepEqual(yield* createWithMode("none", "none"), {
2427 disabled: true,2444 disabled: "t3.json",
2428 inner: false,2445 inner: false,
2429 nested: false,2446 nested: false,
2430 });2447 });
apps/server/src/vcs/GitVcsDriverCore.ts+28-19
...@@ -32,6 +32,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";...@@ -32,6 +32,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
32import { compactTraceAttributes } from "@t3tools/shared/observability";32import { compactTraceAttributes } from "@t3tools/shared/observability";
33import { decodeJsonResult } from "@t3tools/shared/schemaJson";33import { decodeJsonResult } from "@t3tools/shared/schemaJson";
34import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";34import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
35import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
35import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";36import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
36import * as GitVcsDriver from "./GitVcsDriver.ts";37import * as GitVcsDriver from "./GitVcsDriver.ts";
37import {38import {
...@@ -3095,28 +3096,36 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3095,28 +3096,36 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3095 // them. Best-effort: the objects are usually already in the parent's3096 // them. Best-effort: the objects are usually already in the parent's
3096 // `.git/modules`, but a first-ever clone needs the network, and failing to3097 // `.git/modules`, but a first-ever clone needs the network, and failing to
3097 // populate a submodule must not roll back the caller's thread. Repos with3098 // 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 through3099 // hundreds of nested submodules opt out or stop at the top level; the
3099 // t3.json, read from the checkout that was just created.3100 // caller resolves that from settings, or the checkout's t3.json decides.
3100 const hasSubmodules = yield* fileSystem3101 const hasSubmodules = yield* fileSystem
3101 .exists(path.join(worktreePath, ".gitmodules"))3102 .exists(path.join(worktreePath, ".gitmodules"))
3102 .pipe(Effect.orElseSucceed(() => false));3103 .pipe(Effect.orElseSucceed(() => false));
3103 const submoduleMode = hasSubmodules3104 const submoduleMode = !hasSubmodules
3104 ? yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe(3105 ? { value: "none" as const, source: "environment" as const }
3105 Effect.flatMap((contents) => {3106 : resolveProjectFileBackedSetting(
3106 const file = parseT3ProjectFile(contents);3107 "worktreeSubmodules",
3107 return file === null3108 options?.submodules ?? null,
3108 ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", {3109 options?.submodules != null
3109 worktreePath,3110 ? null
3110 }).pipe(Effect.as("recursive" as const))3111 : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe(
3111 : Effect.succeed(file.worktreeSubmodules ?? "recursive");3112 Effect.flatMap((contents) => {
3112 }),3113 const file = parseT3ProjectFile(contents);
3113 Effect.orElseSucceed(() => "recursive" as const),3114 return file === null
3114 )3115 ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", {
3115 : "none";3116 worktreePath,
3116 if (hasSubmodules && submoduleMode === "none" && progress?.onSubmodulesDisabled) {3117 }).pipe(Effect.as(null))
3117 yield* progress.onSubmodulesDisabled();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 });
3118 }3127 }
3119 if (submoduleMode !== "none") {3128 if (submoduleMode.value !== "none") {
3120 if (progress?.onSubmodulesStarted) {3129 if (progress?.onSubmodulesStarted) {
3121 yield* progress.onSubmodulesStarted();3130 yield* progress.onSubmodulesStarted();
3122 }3131 }
...@@ -3124,7 +3133,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3124,7 +3133,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3124 yield* runGit(3133 yield* runGit(
3125 "GitVcsDriver.createWorktree.updateSubmodules",3134 "GitVcsDriver.createWorktree.updateSubmodules",
3126 worktreePath,3135 worktreePath,
3127 submoduleMode === "recursive"3136 submoduleMode.value === "recursive"
3128 ? ["submodule", "update", "--init", "--recursive"]3137 ? ["submodule", "update", "--init", "--recursive"]
3129 : ["submodule", "update", "--init"],3138 : ["submodule", "update", "--init"],
3130 onSubmoduleLine3139 onSubmoduleLine
apps/server/src/ws.ts+37-2
...@@ -81,6 +81,7 @@ import {...@@ -81,6 +81,7 @@ import {
81 type WorktreeSetupSnapshot,81 type WorktreeSetupSnapshot,
82} from "@t3tools/contracts";82} from "@t3tools/contracts";
83import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";83import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
84import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
84import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";85import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
85import { RpcSerialization, RpcServer } from "effect/unstable/rpc";86import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
8687
...@@ -1044,6 +1045,35 @@ const makeWsRpcLayer = (...@@ -1044,6 +1045,35 @@ const makeWsRpcLayer = (
1044 return output;1045 return output;
1045 });1046 });
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
1047 const dispatchBootstrapTurnStart = (1077 const dispatchBootstrapTurnStart = (
1048 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,1078 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
1049 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>1079 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
...@@ -1462,6 +1492,10 @@ const makeWsRpcLayer = (...@@ -1462,6 +1492,10 @@ const makeWsRpcLayer = (
1462 }1492 }
1463 yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running");1493 yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running");
1464 let checkoutTotal: number | null = null;1494 let checkoutTotal: number | null = null;
1495 const submodules = yield* resolveBootstrapWorktreeSubmodules({
1496 threadId,
1497 projectId: targetProjectId ?? null,
1498 });
1465 const worktree = yield* gitWorkflow.createWorktree(1499 const worktree = yield* gitWorkflow.createWorktree(
1466 {1500 {
1467 cwd: prepareWorktree.projectCwd,1501 cwd: prepareWorktree.projectCwd,
...@@ -1471,6 +1505,7 @@ const makeWsRpcLayer = (...@@ -1471,6 +1505,7 @@ const makeWsRpcLayer = (
1471 path: null,1505 path: null,
1472 },1506 },
1473 {1507 {
1508 submodules,
1474 progress: {1509 progress: {
1475 // Git has registered the directory at this point, so a1510 // Git has registered the directory at this point, so a
1476 // cancel during the submodule step can still remove it.1511 // cancel during the submodule step can still remove it.
...@@ -1500,12 +1535,12 @@ const makeWsRpcLayer = (...@@ -1500,12 +1535,12 @@ const makeWsRpcLayer = (
1500 worktreeSetupTracker.stageStatus(threadId, "submodules", "running"),1535 worktreeSetupTracker.stageStatus(threadId, "submodules", "running"),
1501 ),1536 ),
1502 ),1537 ),
1503 onSubmodulesDisabled: () =>1538 onSubmodulesDisabled: ({ source }) =>
1504 worktreeSetupTracker.stageStatus(1539 worktreeSetupTracker.stageStatus(
1505 threadId,1540 threadId,
1506 "submodules",1541 "submodules",
1507 "skipped",1542 "skipped",
1508 "disabled in t3.json",1543 `disabled in ${source}`,
1509 ),1544 ),
1510 onSubmoduleLine: (line) => {1545 onSubmoduleLine: (line) => {
1511 const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1];1546 const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1];
apps/web/src/components/BranchToolbar.logic.ts+13-1
...@@ -1,4 +1,10 @@...@@ -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";
2import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
3import { toSortableTimestamp } from "../lib/threadSort";9import { toSortableTimestamp } from "../lib/threadSort";
4export {10export {
...@@ -86,6 +92,12 @@ export function resolveEnvModeLabel(mode: EnvMode): string {...@@ -86,6 +92,12 @@ export function resolveEnvModeLabel(mode: EnvMode): string {
86 return mode === "worktree" ? "New worktree" : "Current checkout";92 return mode === "worktree" ? "New worktree" : "Current checkout";
87}93}
8894
95export const WORKTREE_SUBMODULES_LABELS: Record<WorktreeSubmodules, string> = {
96 recursive: "Recursive",
97 "top-level": "Top level only",
98 none: "Skip",
99};
100
89export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {101export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {
90 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");102 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
91}103}
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+54-1
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 type ModelSelection,3 type ModelSelection,
4 type ProviderInstanceId,4 type ProviderInstanceId,
5 type WorktreeSubmodules,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { createModelSelection } from "@t3tools/shared/model";7import { createModelSelection } from "@t3tools/shared/model";
7import { resolveProjectSettings } from "@t3tools/shared/projectSettings";8import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
...@@ -16,7 +17,7 @@ import {...@@ -16,7 +17,7 @@ import {
16} from "../../providerInstances";17} from "../../providerInstances";
17import { useEnvironments } from "../../state/environments";18import { useEnvironments } from "../../state/environments";
18import { EMPTY_SERVER_PROVIDERS } from "../../state/server";19import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
19import { resolveEnvModeLabel } from "../BranchToolbar.logic";20import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic";
20import { ProviderModelPicker } from "../chat/ProviderModelPicker";21import { ProviderModelPicker } from "../chat/ProviderModelPicker";
21import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig";22import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig";
22import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";23import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
...@@ -45,6 +46,11 @@ import {...@@ -45,6 +46,11 @@ import {
45 * environment defaults at an environment scope and project overrides at a46 * environment defaults at an environment scope and project overrides at a
46 * project or checkout scope; the scoped hooks route the write.47 * project or checkout scope; the scoped hooks route the write.
47 */48 */
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
48export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) {54export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) {
49 const { scope, target, targets, connectedEnvironments } = useSettingsScope();55 const { scope, target, targets, connectedEnvironments } = useSettingsScope();
50 const settings = useScopedSettings();56 const settings = useScopedSettings();
...@@ -70,6 +76,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -70,6 +76,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
70 const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]);76 const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]);
71 const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon;77 const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon;
72 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);78 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
79 const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);
73 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);80 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
74 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);81 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
75 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);82 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
...@@ -319,6 +326,52 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -319,6 +326,52 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
319 </Select>326 </Select>
320 }327 }
321 />328 />
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 />
322 </>375 </>
323 ) : category === "source-control" ? (376 ) : category === "source-control" ? (
324 <>377 <>
apps/web/src/components/settings/SettingInheritance.tsx+6-2
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 type ProjectFileBackedSettingKey,4 type ProjectFileBackedSettingKey,
5 resolveEnvironmentMachineKind,5 resolveEnvironmentMachineKind,
6 type ServerSettings,6 type ServerSettings,
7 type WorktreeSubmodules,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { CheckIcon, LayersIcon } from "lucide-react";9import { CheckIcon, LayersIcon } from "lucide-react";
9import * as Equal from "effect/Equal";10import * as Equal from "effect/Equal";
...@@ -11,7 +12,7 @@ import * as Equal from "effect/Equal";...@@ -11,7 +12,7 @@ import * as Equal from "effect/Equal";
11import { cn } from "../../lib/utils";12import { cn } from "../../lib/utils";
12import type { EnvironmentPresentation } from "../../state/environments";13import type { EnvironmentPresentation } from "../../state/environments";
13import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";14import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
14import { resolveEnvModeLabel } from "../BranchToolbar.logic";15import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic";
15import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";16import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
16import { Button, InlineButton } from "../ui/button";17import { Button, InlineButton } from "../ui/button";
17import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";18import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
...@@ -44,7 +45,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {...@@ -44,7 +45,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
44 ? "Automatic"45 ? "Automatic"
45 : key === "sourceControlWriterModelSelection"46 : key === "sourceControlWriterModelSelection"
46 ? "Text generation model"47 ? "Text generation model"
47 : key === "defaultThreadEnvMode"48 : key === "defaultThreadEnvMode" || key === "worktreeSubmodules"
48 ? "Inherit"49 ? "Inherit"
49 : "Not set";50 : "Not set";
50 }51 }
...@@ -58,6 +59,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {...@@ -58,6 +59,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
58 if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) {59 if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) {
59 return resolveEnvModeLabel(value);60 return resolveEnvModeLabel(value);
60 }61 }
62 if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) {
63 return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules];
64 }
61 if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {65 if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {
62 return PULL_REQUEST_MERGE_METHOD_LABELS[66 return PULL_REQUEST_MERGE_METHOD_LABELS[
63 value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS67 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 = [...@@ -401,6 +401,13 @@ export const SETTINGS_SEARCH_ITEMS = [
401 scope: "project-defaults",401 scope: "project-defaults",
402 searchTerms: ["default workspace mode draft local worktree"],402 searchTerms: ["default workspace mode draft local worktree"],
403 },403 },
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 },
404 {411 {
405 id: "start-from-origin",412 id: "start-from-origin",
406 title: "Start from origin",413 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....@@ -55,8 +55,11 @@ built-in default. Leave a setting on **Inherit** to let the next tier decide.
55Browser access changes apply when an agent session next starts.55Browser access changes apply when an agent session next starts.
5656
57New worktrees initialize git submodules recursively. If that step is slow because the repository57New 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 stop58declares many nested submodules, set **Submodules** in **Settings → General** (with the project
59at the ones the repository declares itself, or `"none"` to leave them for a setup script.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
61## Storage cleanup64## Storage cleanup
6265
packages/contracts/src/settings.test.ts+9
...@@ -835,6 +835,15 @@ describe("ServerSettings worktree defaults", () => {...@@ -835,6 +835,15 @@ describe("ServerSettings worktree defaults", () => {
835 decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin,835 decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin,
836 ).toBe(false);836 ).toBe(false);
837 });837 });
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 });
838});847});
839848
840describe("ServerSettings.sourceControlWritingStyle", () => {849describe("ServerSettings.sourceControlWritingStyle", () => {
packages/contracts/src/settings.ts+13-1
...@@ -5,13 +5,14 @@ import * as Schema from "effect/Schema";...@@ -5,13 +5,14 @@ import * as Schema from "effect/Schema";
5import * as SchemaTransformation from "effect/SchemaTransformation";5import * as SchemaTransformation from "effect/SchemaTransformation";
6import {6import {
7 ForwardCompatibleNullable,7 ForwardCompatibleNullable,
8 ForwardCompatibleOptional,
8 OmittedWhenNull,9 OmittedWhenNull,
9 ProjectId,10 ProjectId,
10 TrimmedNonEmptyString,11 TrimmedNonEmptyString,
11 TrimmedString,12 TrimmedString,
12} from "./baseSchemas.ts";13} from "./baseSchemas.ts";
13import { UsageLimitSourceId } from "./usageLimitSourceId.ts";14import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
14import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";15import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";
15import { KeybindingShortcut } from "./keybindings.ts";16import { KeybindingShortcut } from "./keybindings.ts";
16import {17import {
17 CustomModelSetting,18 CustomModelSetting,
...@@ -1001,6 +1002,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [...@@ -1001,6 +1002,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
1001 "defaultRuntimeMode",1002 "defaultRuntimeMode",
1002 "defaultThreadEnvMode",1003 "defaultThreadEnvMode",
1003 "newWorktreesStartFromOrigin",1004 "newWorktreesStartFromOrigin",
1005 "worktreeSubmodules",
1004 "defaultAutoPull",1006 "defaultAutoPull",
1005 "defaultProjectScripts",1007 "defaultProjectScripts",
1006 "enableAgentBrowserAccess",1008 "enableAgentBrowserAccess",
...@@ -1027,6 +1029,7 @@ export const ProjectSettingsOverrides = Schema.Struct({...@@ -1027,6 +1029,7 @@ export const ProjectSettingsOverrides = Schema.Struct({
1027 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),1029 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
1028 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),1030 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
1029 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),1031 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
1032 worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules),
1030 defaultAutoPull: Schema.optionalKey(Schema.Boolean),1033 defaultAutoPull: Schema.optionalKey(Schema.Boolean),
1031 defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),1034 defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),
1032 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),1035 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
...@@ -1208,6 +1211,14 @@ export const ServerSettings = Schema.Struct({...@@ -1208,6 +1211,14 @@ export const ServerSettings = Schema.Struct({
1208 newWorktreesStartFromOrigin: Schema.Boolean.pipe(1211 newWorktreesStartFromOrigin: Schema.Boolean.pipe(
1209 Schema.withDecodingDefault(Effect.succeed(true)),1212 Schema.withDecodingDefault(Effect.succeed(true)),
1210 ),1213 ),
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 ),
1211 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),1222 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
1212 textGenerationModelSelection: ModelSelection.pipe(1223 textGenerationModelSelection: ModelSelection.pipe(
1213 Schema.withDecodingDefault(1224 Schema.withDecodingDefault(
...@@ -1499,6 +1510,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1499,6 +1510,7 @@ export const ServerSettingsPatch = Schema.Struct({
1499 environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)),1510 environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)),
1500 defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)),1511 defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)),
1501 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),1512 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
1513 worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)),
1502 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),1514 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
1503 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),1515 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
1504 sourceControlWritingStyle: Schema.optionalKey(1516 sourceControlWritingStyle: Schema.optionalKey(
packages/contracts/src/t3ProjectFile.ts+2-1
...@@ -90,7 +90,7 @@ export const T3ProjectFile = Schema.Struct({...@@ -90,7 +90,7 @@ export const T3ProjectFile = Schema.Struct({
90 worktreeSubmodules: Schema.optionalKey(90 worktreeSubmodules: Schema.optionalKey(
91 WorktreeSubmodules.annotate({91 WorktreeSubmodules.annotate({
92 description:92 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.',
94 }),94 }),
95 ),95 ),
96 scripts: Schema.optionalKey(96 scripts: Schema.optionalKey(
...@@ -118,6 +118,7 @@ export type T3ProjectFile = typeof T3ProjectFile.Type;...@@ -118,6 +118,7 @@ export type T3ProjectFile = typeof T3ProjectFile.Type;
118 */118 */
119export const PROJECT_FILE_BACKED_SETTINGS = {119export const PROJECT_FILE_BACKED_SETTINGS = {
120 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },120 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },
121 worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" },
121} as const satisfies {122} as const satisfies {
122 readonly [K in ProjectScopedServerSettingKey]?: {123 readonly [K in ProjectScopedServerSettingKey]?: {
123 readonly field: {124 readonly field: {
packages/shared/src/projectSettings.test.ts+18
...@@ -9,6 +9,7 @@ import { createModelSelection } from "./model.ts";...@@ -9,6 +9,7 @@ import { createModelSelection } from "./model.ts";
9import {9import {
10 clearProjectSettingsOverrides,10 clearProjectSettingsOverrides,
11 hasProjectSettingsOverrides,11 hasProjectSettingsOverrides,
12 resolveProjectFileBackedSetting,
12 resolveProjectSettings,13 resolveProjectSettings,
13 resolveWorktreeCleanup,14 resolveWorktreeCleanup,
14 withProjectSettingsOverrides,15 withProjectSettingsOverrides,
...@@ -187,6 +188,23 @@ describe("resolveProjectSettings with a t3.json", () => {...@@ -187,6 +188,23 @@ describe("resolveProjectSettings with a t3.json", () => {
187 ).toBe("environment");188 ).toBe("environment");
188 });189 });
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
190 it("leaves settings untouched when no file is passed", () => {208 it("leaves settings untouched when no file is passed", () => {
191 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe(209 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe(
192 DEFAULT_SERVER_SETTINGS,210 DEFAULT_SERVER_SETTINGS,
packages/shared/src/projectSettings.ts+27-6
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 type ModelSelection,2 type ModelSelection,
3 PROJECT_FILE_BACKED_SETTINGS,3 PROJECT_FILE_BACKED_SETTINGS,
4 PROJECT_SCOPED_SERVER_SETTING_KEYS,4 PROJECT_SCOPED_SERVER_SETTING_KEYS,
5 type ProjectFileBackedSettingKey,
5 type ProjectId,6 type ProjectId,
6 type ProjectScopedServerSettingKey,7 type ProjectScopedServerSettingKey,
7 type ProjectSettingsOverrides,8 type ProjectSettingsOverrides,
...@@ -104,22 +105,42 @@ function applyProjectFile(...@@ -104,22 +105,42 @@ function applyProjectFile(
104): ResolvedProjectSettings {105): ResolvedProjectSettings {
105 let effective: Record<string, unknown> | null = null;106 let effective: Record<string, unknown> | null = null;
106 let sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> | null = null;107 let sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> | null = null;
107 for (const [key, { field, builtIn }] of Object.entries(PROJECT_FILE_BACKED_SETTINGS)) {108 for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) {
108 const settingKey = key as ProjectScopedServerSettingKey;109 if (resolved.settings[key] !== null) continue;
109 if (resolved.settings[settingKey] !== null) continue;110 const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile);
110 const fromFile = projectFile?.[field];
111 effective ??= { ...resolved.settings };111 effective ??= { ...resolved.settings };
112 sources ??= { ...resolved.sources };112 sources ??= { ...resolved.sources };
113 effective[settingKey] = fromFile ?? builtIn;113 effective[key] = value;
114 // A project override of null defers like an unset one, so the value did114 // A project override of null defers like an unset one, so the value did
115 // not come from the project either way.115 // not come from the project either way.
116 sources[settingKey] = fromFile === undefined ? "environment" : "t3.json";116 sources[key] = source;
117 }117 }
118 return effective === null || sources === null118 return effective === null || sources === null
119 ? resolved119 ? resolved
120 : { ...resolved, settings: effective as ServerSettings, sources };120 : { ...resolved, settings: effective as ServerSettings, sources };
121}121}
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
123function resolveProjectOverrides(144function resolveProjectOverrides(
124 settings: ServerSettings,145 settings: ServerSettings,
125 projectId: ProjectId | null,146 projectId: ProjectId | null,