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
log1262d2f3ab2940c1d47ca4f5c4ca42e5c7259765
tree7e095d889fe12277f187d3ced55faad9ed73cb5b
parent76cc9b08f19d89012f16d18f47478c4f7b9b0a6f
signature Signed by PGP key B5690EEEBB952194

feat(server): let t3.json limit or disable submodule init in new worktrees (#12953)

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

9 files changed, 137 insertions(+), 4 deletions(-)

apps/server/src/vcs/GitVcsDriver.ts+2
......@@ -128,6 +128,8 @@ export interface CreateWorktreeProgress {
128128 total: number;
129129 }) => Effect.Effect<void, never>;
130130 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>;
131133 readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>;
132134 readonly onSubmodulesFinished?: (input: {
133135 ok: boolean;
apps/server/src/vcs/GitVcsDriverCore.test.ts+78
......@@ -2353,6 +2353,84 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
23532353 }),
23542354 );
23552355
2356 it.effect("honors the t3.json worktreeSubmodules setting", () =>
2357 Effect.gen(function* () {
2358 const fileSystem = yield* FileSystem.FileSystem;
2359 const pathService = yield* Path.Path;
2360
2361 const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL;
2362 process.env.GIT_ALLOW_PROTOCOL = "file";
2363 yield* Effect.addFinalizer(() =>
2364 Effect.sync(() => {
2365 if (previousAllowedProtocol === undefined) {
2366 delete process.env.GIT_ALLOW_PROTOCOL;
2367 } else {
2368 process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol;
2369 }
2370 }),
2371 );
2372
2373 // inner -> nested, so a recursive init populates nested/NESTED.md and
2374 // a top-level init leaves it empty.
2375 const nestedRepo = yield* makeTmpDir("git-nested-");
2376 yield* initRepoWithCommit(nestedRepo);
2377 yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n");
2378 yield* git(nestedRepo, ["add", "."]);
2379 yield* git(nestedRepo, ["commit", "-m", "nested"]);
2380 const innerRepo = yield* makeTmpDir("git-inner-");
2381 yield* initRepoWithCommit(innerRepo);
2382 yield* writeTextFile(innerRepo, "INNER.md", "# inner\n");
2383 yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]);
2384 yield* git(innerRepo, ["add", "."]);
2385 yield* git(innerRepo, ["commit", "-m", "inner"]);
2386
2387 const cwd = yield* makeTmpDir();
2388 const { initialBranch } = yield* initRepoWithCommit(cwd);
2389 yield* git(cwd, ["submodule", "add", innerRepo, "inner"]);
2390 yield* git(cwd, ["commit", "-m", "add submodule"]);
2391 const driver = yield* GitVcsDriver.GitVcsDriver;
2392 const worktreesDir = yield* makeTmpDir("git-worktrees-");
2393
2394 const createWithMode = Effect.fn(function* (
2395 fileMode: "recursive" | "top-level" | "none",
2396 branch: string,
2397 ) {
2398 yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`);
2399 yield* git(cwd, ["add", "t3.json"]);
2400 yield* git(cwd, ["commit", "-m", `submodules: ${fileMode}`]);
2401 const worktreePath = pathService.join(worktreesDir, branch);
2402 const disabled = yield* Ref.make(false);
2403 yield* driver.createWorktree(
2404 { cwd, path: worktreePath, refName: initialBranch, newRefName: branch },
2405 { progress: { onSubmodulesDisabled: () => Ref.set(disabled, true) } },
2406 );
2407 return {
2408 disabled: yield* Ref.get(disabled),
2409 inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")),
2410 nested: yield* fileSystem.exists(
2411 pathService.join(worktreePath, "inner", "nested", "NESTED.md"),
2412 ),
2413 };
2414 });
2415
2416 assert.deepEqual(yield* createWithMode("recursive", "recursive"), {
2417 disabled: false,
2418 inner: true,
2419 nested: true,
2420 });
2421 assert.deepEqual(yield* createWithMode("top-level", "top-level"), {
2422 disabled: false,
2423 inner: true,
2424 nested: false,
2425 });
2426 assert.deepEqual(yield* createWithMode("none", "none"), {
2427 disabled: true,
2428 inner: false,
2429 nested: false,
2430 });
2431 }),
2432 );
2433
23562434 it.effect("reports checkout progress during parallel worktree creation", () =>
23572435 Effect.gen(function* () {
23582436 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+25-3
......@@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2020
2121import {
2222 GitCommandError,
23 T3_PROJECT_FILE_NAME,
2324 type ReviewDiffFileContentsInput,
2425 type ReviewDiffPreviewInput,
2526 type ReviewDiffFileStat,
......@@ -30,6 +31,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3
3031import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3132import { compactTraceAttributes } from "@t3tools/shared/observability";
3233import { decodeJsonResult } from "@t3tools/shared/schemaJson";
34import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
3335import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
3436import * as GitVcsDriver from "./GitVcsDriver.ts";
3537import {
......@@ -3092,11 +3094,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
30923094 // skills, tooling or source in one gets a worktree that is quietly missing
30933095 // them. Best-effort: the objects are usually already in the parent's
30943096 // `.git/modules`, but a first-ever clone needs the network, and failing to
3095 // populate a submodule must not roll back the caller's thread.
3097 // 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.
30963100 const hasSubmodules = yield* fileSystem
30973101 .exists(path.join(worktreePath, ".gitmodules"))
30983102 .pipe(Effect.orElseSucceed(() => false));
3099 if (hasSubmodules) {
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();
3118 }
3119 if (submoduleMode !== "none") {
31003120 if (progress?.onSubmodulesStarted) {
31013121 yield* progress.onSubmodulesStarted();
31023122 }
......@@ -3104,7 +3124,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
31043124 yield* runGit(
31053125 "GitVcsDriver.createWorktree.updateSubmodules",
31063126 worktreePath,
3107 ["submodule", "update", "--init", "--recursive"],
3127 submoduleMode === "recursive"
3128 ? ["submodule", "update", "--init", "--recursive"]
3129 : ["submodule", "update", "--init"],
31083130 onSubmoduleLine
31093131 ? {
31103132 env: { LC_ALL: "C" },
apps/server/src/ws.ts+7
......@@ -1500,6 +1500,13 @@ const makeWsRpcLayer = (
15001500 worktreeSetupTracker.stageStatus(threadId, "submodules", "running"),
15011501 ),
15021502 ),
1503 onSubmodulesDisabled: () =>
1504 worktreeSetupTracker.stageStatus(
1505 threadId,
1506 "submodules",
1507 "skipped",
1508 "disabled in t3.json",
1509 ),
15031510 onSubmoduleLine: (line) => {
15041511 const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1];
15051512 return submodulePath === undefined
docs/user/project-settings.md+4
......@@ -52,6 +52,10 @@ on each selected environment, and reset returns to the environment's shared list
5252For workspace mode, a project's `t3.json` preference applies when the project has no override.
5353Browser access changes apply when an agent session next starts.
5454
55New worktrees initialize git submodules recursively. If that step is slow because the repository
56declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop
57at the ones the repository declares itself, or `"none"` to leave them for a setup script.
58
5559## Storage cleanup
5660
5761Open **Settings → Storage** to enable automatic cleanup on one machine or all connected
packages/contracts/src/environment.ts+7
......@@ -58,6 +58,13 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({
5858 */
5959export const ThreadEnvMode = Schema.Literals(["local", "worktree"]);
6060export type ThreadEnvMode = typeof ThreadEnvMode.Type;
61
62/**
63 * How a new worktree populates git submodules: every nested level, only the
64 * ones this repository declares, or not at all.
65 */
66export const WorktreeSubmodules = Schema.Literals(["recursive", "top-level", "none"]);
67export type WorktreeSubmodules = typeof WorktreeSubmodules.Type;
6168export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
6269
6370/** How a server can replace itself with another version when asked over RPC.
packages/contracts/src/t3ProjectFile.test.ts+6
......@@ -60,4 +60,10 @@ describe("T3ProjectFile", () => {
6060 expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local");
6161 expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow();
6262 });
63
64 it("decodes worktreeSubmodules and rejects unknown modes", () => {
65 expect(decode({ worktreeSubmodules: "none" }).worktreeSubmodules).toBe("none");
66 expect(decode({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe("top-level");
67 expect(() => decode({ worktreeSubmodules: "shallow" })).toThrow();
68 });
6369});
packages/contracts/src/t3ProjectFile.ts+7-1
......@@ -1,7 +1,7 @@
11import * as Schema from "effect/Schema";
22import * as SchemaTransformation from "effect/SchemaTransformation";
33
4import { ThreadEnvMode } from "./environment.ts";
4import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";
55import { ProjectScriptIcon } from "./orchestration.ts";
66
77/** File name of the checked-in T3 project file, resolved at the workspace root. */
......@@ -86,6 +86,12 @@ export const T3ProjectFile = Schema.Struct({
8686 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.',
8787 }),
8888 ),
89 worktreeSubmodules: Schema.optionalKey(
90 WorktreeSubmodules.annotate({
91 description:
92 '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 }),
94 ),
8995 scripts: Schema.optionalKey(
9096 Schema.Array(T3ProjectFileScript)
9197 .annotate({
packages/shared/src/t3ProjectFile.test.ts+1
......@@ -36,6 +36,7 @@ describe("buildT3ProjectFileJsonSchema", () => {
3636 "defaultThreadEnvMode",
3737 "iconPath",
3838 "scripts",
39 "worktreeSubmodules",
3940 ]);
4041 expect(schema.required).toBeUndefined();
4142 expect(schema.properties.iconPath?.description).toContain("Workspace-relative path");