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