diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2aaabeed3d7a..040862eaf6a8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -128,6 +128,8 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; + /** Fires when `.gitmodules` exists but t3.json sets `worktreeSubmodules` to `"none"`. */ + readonly onSubmodulesDisabled?: () => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f798abba74b6..045f6d15cfe6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2353,6 +2353,84 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("honors the t3.json worktreeSubmodules setting", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: "recursive" | "top-level" | "none", + branch: string, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + yield* git(cwd, ["commit", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { progress: { onSubmodulesDisabled: () => Ref.set(disabled, true) } }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: true, + inner: false, + nested: false, + }); + }), + ); + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 56cff0cb9a58..18587c9f36ef 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + T3_PROJECT_FILE_NAME, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -30,6 +31,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -3092,11 +3094,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. + // populate a submodule must not roll back the caller's thread. Repos with + // hundreds of nested submodules opt out or stop at the top level through + // t3.json, read from the checkout that was just created. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { + const submoduleMode = hasSubmodules + ? yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as("recursive" as const)) + : Effect.succeed(file.worktreeSubmodules ?? "recursive"); + }), + Effect.orElseSucceed(() => "recursive" as const), + ) + : "none"; + if (hasSubmodules && submoduleMode === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled(); + } + if (submoduleMode !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3104,7 +3124,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - ["submodule", "update", "--init", "--recursive"], + submoduleMode === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], onSubmoduleLine ? { env: { LC_ALL: "C" }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 90271e00adfb..909216d89349 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1500,6 +1500,13 @@ const makeWsRpcLayer = ( worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), + onSubmodulesDisabled: () => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + "disabled in t3.json", + ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; return submodulePath === undefined diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 0d5d7e66b1fb..6144bd960100 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -52,6 +52,10 @@ on each selected environment, and reset returns to the environment's shared list For workspace mode, a project's `t3.json` preference applies when the project has no override. Browser access changes apply when an agent session next starts. +New worktrees initialize git submodules recursively. If that step is slow because the repository +declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop +at the ones the repository declares itself, or `"none"` to leave them for a setup script. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 70888f2988e6..b5a3290f6314 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -58,6 +58,13 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ */ export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; + +/** + * How a new worktree populates git submodules: every nested level, only the + * ones this repository declares, or not at all. + */ +export const WorktreeSubmodules = Schema.Literals(["recursive", "top-level", "none"]); +export type WorktreeSubmodules = typeof WorktreeSubmodules.Type; export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; /** How a server can replace itself with another version when asked over RPC. diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index 4352449c7ade..64128a4990b0 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -60,4 +60,10 @@ describe("T3ProjectFile", () => { expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local"); expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow(); }); + + it("decodes worktreeSubmodules and rejects unknown modes", () => { + expect(decode({ worktreeSubmodules: "none" }).worktreeSubmodules).toBe("none"); + expect(decode({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe("top-level"); + expect(() => decode({ worktreeSubmodules: "shallow" })).toThrow(); + }); }); diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 6bd4168054d5..2e31e5271f7d 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ThreadEnvMode } from "./environment.ts"; +import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { ProjectScriptIcon } from "./orchestration.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ @@ -86,6 +86,12 @@ export const T3ProjectFile = Schema.Struct({ '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.', }), ), + worktreeSubmodules: Schema.optionalKey( + WorktreeSubmodules.annotate({ + description: + 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle.', + }), + ), scripts: Schema.optionalKey( Schema.Array(T3ProjectFileScript) .annotate({ diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index 924a01c4414b..42045cc4284f 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -36,6 +36,7 @@ describe("buildT3ProjectFileJsonSchema", () => { "defaultThreadEnvMode", "iconPath", "scripts", + "worktreeSubmodules", ]); expect(schema.required).toBeUndefined(); expect(schema.properties.iconPath?.description).toContain("Workspace-relative path");