| author | |
| committer | |
| log | 1262d2f3ab2940c1d47ca4f5c4ca42e5c7259765 |
| tree | 7e095d889fe12277f187d3ced55faad9ed73cb5b |
| parent | 76cc9b08f19d89012f16d18f47478c4f7b9b0a6f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 128 | 128 | total: number; |
| 129 | 129 | }) => Effect.Effect<void, never>; |
| 130 | 130 | 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>; | |
| 131 | 133 | readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>; |
| 132 | 134 | readonly onSubmodulesFinished?: (input: { |
| 133 | 135 | ok: boolean; |
apps/server/src/vcs/GitVcsDriverCore.test.ts+78| ... | ... | @@ -2353,6 +2353,84 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2353 | 2353 | }), |
| 2354 | 2354 | ); |
| 2355 | 2355 | |
| 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 | ||
| 2356 | 2434 | it.effect("reports checkout progress during parallel worktree creation", () => |
| 2357 | 2435 | Effect.gen(function* () { |
| 2358 | 2436 | const cwd = yield* makeTmpDir(); |
apps/server/src/vcs/GitVcsDriverCore.ts+25-3| ... | ... | @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | 20 | |
| 21 | 21 | import { |
| 22 | 22 | GitCommandError, |
| 23 | T3_PROJECT_FILE_NAME, | |
| 23 | 24 | type ReviewDiffFileContentsInput, |
| 24 | 25 | type ReviewDiffPreviewInput, |
| 25 | 26 | type ReviewDiffFileStat, |
| ... | ... | @@ -30,6 +31,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 |
| 30 | 31 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 31 | 32 | import { compactTraceAttributes } from "@t3tools/shared/observability"; |
| 32 | 33 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 34 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; | |
| 33 | 35 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| 34 | 36 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 35 | 37 | import { |
| ... | ... | @@ -3092,11 +3094,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3092 | 3094 | // skills, tooling or source in one gets a worktree that is quietly missing |
| 3093 | 3095 | // them. Best-effort: the objects are usually already in the parent's |
| 3094 | 3096 | // `.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. | |
| 3096 | 3100 | const hasSubmodules = yield* fileSystem |
| 3097 | 3101 | .exists(path.join(worktreePath, ".gitmodules")) |
| 3098 | 3102 | .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") { | |
| 3100 | 3120 | if (progress?.onSubmodulesStarted) { |
| 3101 | 3121 | yield* progress.onSubmodulesStarted(); |
| 3102 | 3122 | } |
| ... | ... | @@ -3104,7 +3124,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3104 | 3124 | yield* runGit( |
| 3105 | 3125 | "GitVcsDriver.createWorktree.updateSubmodules", |
| 3106 | 3126 | worktreePath, |
| 3107 | ["submodule", "update", "--init", "--recursive"], | |
| 3127 | submoduleMode === "recursive" | |
| 3128 | ? ["submodule", "update", "--init", "--recursive"] | |
| 3129 | : ["submodule", "update", "--init"], | |
| 3108 | 3130 | onSubmoduleLine |
| 3109 | 3131 | ? { |
| 3110 | 3132 | env: { LC_ALL: "C" }, |
apps/server/src/ws.ts+7| ... | ... | @@ -1500,6 +1500,13 @@ const makeWsRpcLayer = ( |
| 1500 | 1500 | worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), |
| 1501 | 1501 | ), |
| 1502 | 1502 | ), |
| 1503 | onSubmodulesDisabled: () => | |
| 1504 | worktreeSetupTracker.stageStatus( | |
| 1505 | threadId, | |
| 1506 | "submodules", | |
| 1507 | "skipped", | |
| 1508 | "disabled in t3.json", | |
| 1509 | ), | |
| 1503 | 1510 | onSubmoduleLine: (line) => { |
| 1504 | 1511 | const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; |
| 1505 | 1512 | 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 |
| 52 | 52 | For workspace mode, a project's `t3.json` preference applies when the project has no override. |
| 53 | 53 | Browser access changes apply when an agent session next starts. |
| 54 | 54 | |
| 55 | New worktrees initialize git submodules recursively. If that step is slow because the repository | |
| 56 | declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop | |
| 57 | at the ones the repository declares itself, or `"none"` to leave them for a setup script. | |
| 58 | ||
| 55 | 59 | ## Storage cleanup |
| 56 | 60 | |
| 57 | 61 | Open **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({ |
| 58 | 58 | */ |
| 59 | 59 | export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); |
| 60 | 60 | export 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 | */ | |
| 66 | export const WorktreeSubmodules = Schema.Literals(["recursive", "top-level", "none"]); | |
| 67 | export type WorktreeSubmodules = typeof WorktreeSubmodules.Type; | |
| 61 | 68 | export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; |
| 62 | 69 | |
| 63 | 70 | /** 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", () => { |
| 60 | 60 | expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local"); |
| 61 | 61 | expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow(); |
| 62 | 62 | }); |
| 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 | }); | |
| 63 | 69 | }); |
packages/contracts/src/t3ProjectFile.ts+7-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 3 | 3 | |
| 4 | import { ThreadEnvMode } from "./environment.ts"; | |
| 4 | import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; | |
| 5 | 5 | import { ProjectScriptIcon } from "./orchestration.ts"; |
| 6 | 6 | |
| 7 | 7 | /** File name of the checked-in T3 project file, resolved at the workspace root. */ |
| ... | ... | @@ -86,6 +86,12 @@ export const T3ProjectFile = Schema.Struct({ |
| 86 | 86 | '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.', |
| 87 | 87 | }), |
| 88 | 88 | ), |
| 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 | ), | |
| 89 | 95 | scripts: Schema.optionalKey( |
| 90 | 96 | Schema.Array(T3ProjectFileScript) |
| 91 | 97 | .annotate({ |
packages/shared/src/t3ProjectFile.test.ts+1| ... | ... | @@ -36,6 +36,7 @@ describe("buildT3ProjectFileJsonSchema", () => { |
| 36 | 36 | "defaultThreadEnvMode", |
| 37 | 37 | "iconPath", |
| 38 | 38 | "scripts", |
| 39 | "worktreeSubmodules", | |
| 39 | 40 | ]); |
| 40 | 41 | expect(schema.required).toBeUndefined(); |
| 41 | 42 | expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); |