Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2191,6 +2191,56 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("skips submodule checkout when t3.json disables it", () =>
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;
}
}),
);

const submoduleRepo = yield* makeTmpDir("git-submodule-");
yield* initRepoWithCommit(submoduleRepo);
yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n");
yield* git(submoduleRepo, ["add", "."]);
yield* git(submoduleRepo, ["commit", "-m", "shared"]);

const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]);
yield* writeTextFile(cwd, "t3.json", '{ "initSubmodulesOnWorktreeCreate": false }\n');
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "add submodule"]);

const worktreePath = pathService.join(
yield* makeTmpDir("git-worktrees-"),
"skip-submodule-worktree",
);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.createWorktree({
cwd,
path: worktreePath,
refName: initialBranch,
newRefName: "feature/skip-submodules",
});

assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, "t3.json")), true);
assert.equal(
yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")),
false,
);
}),
);

it.effect("still creates the worktree when submodule checkout fails", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import {
GitCommandError,
T3_PROJECT_FILE_NAME,
type ReviewDiffFileContentsInput,
type ReviewDiffPreviewInput,
type ReviewDiffFileStat,
Expand All @@ -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 {
Expand Down Expand Up @@ -3048,9 +3050,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
// 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.
const hasSubmodules = yield* fileSystem
.exists(path.join(worktreePath, ".gitmodules"))
.pipe(Effect.orElseSucceed(() => false));
// A t3.json with initSubmodulesOnWorktreeCreate: false skips this so a
// runOnWorktreeCreate script can initialize a subset instead.
const projectFile = yield* fileSystem
.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME))
.pipe(
Effect.map(parseT3ProjectFile),
Effect.orElseSucceed(() => null),
);
const initSubmodules = projectFile?.initSubmodulesOnWorktreeCreate !== false;
const hasSubmodules = initSubmodules
? yield* fileSystem
.exists(path.join(worktreePath, ".gitmodules"))
.pipe(Effect.orElseSucceed(() => false))
: false;
if (hasSubmodules) {
if (progress?.onSubmodulesStarted) {
yield* progress.onSubmodulesStarted();
Expand Down
5 changes: 5 additions & 0 deletions docs/user/project-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ checkouts and removal. Actions belong to a project: editing them creates the pro
on each selected environment, and reset returns to the environment's shared list. A project's
`t3.json` actions can be imported there.

New worktrees run `git submodule update --init --recursive` when the checkout has a `.gitmodules`
file. Set `initSubmodulesOnWorktreeCreate` to `false` in `t3.json` to skip that step and initialize
the submodules you need from a `runOnWorktreeCreate` action instead. Set that action's `async` field
to `false` if the agent should wait until those submodules exist.

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.

Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/t3ProjectFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,15 @@ describe("T3ProjectFile", () => {
expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local");
expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow();
});

it("decodes initSubmodulesOnWorktreeCreate", () => {
expect(decode({}).initSubmodulesOnWorktreeCreate).toBeUndefined();
expect(decode({ initSubmodulesOnWorktreeCreate: true }).initSubmodulesOnWorktreeCreate).toBe(
true,
);
expect(decode({ initSubmodulesOnWorktreeCreate: false }).initSubmodulesOnWorktreeCreate).toBe(
false,
);
expect(() => decode({ initSubmodulesOnWorktreeCreate: "false" })).toThrow();
});
});
6 changes: 6 additions & 0 deletions packages/contracts/src/t3ProjectFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
}),
),
initSubmodulesOnWorktreeCreate: Schema.optionalKey(
Schema.Boolean.annotate({
description:
"When true or omitted, T3 Code runs `git submodule update --init --recursive` after creating a worktree. Set false to skip that step and initialize submodules from a runOnWorktreeCreate script instead.",
}),
),
scripts: Schema.optionalKey(
Schema.Array(T3ProjectFileScript)
.annotate({
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/t3ProjectFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ describe("buildT3ProjectFileJsonSchema", () => {
"$schema",
"defaultThreadEnvMode",
"iconPath",
"initSubmodulesOnWorktreeCreate",
"scripts",
]);
expect(schema.required).toBeUndefined();
expect(schema.properties.iconPath?.description).toContain("Workspace-relative path");
expect(schema.properties.defaultThreadEnvMode?.description).toContain("new threads start");
expect(schema.properties.initSubmodulesOnWorktreeCreate?.description).toContain(
"submodule update",
);

const script = schema.properties.scripts?.items;
expect(script?.required).toEqual(["name", "command"]);
Expand Down Expand Up @@ -84,6 +88,9 @@ describe("parseT3ProjectFile", () => {
expect(parseT3ProjectFile('{ "defaultThreadEnvMode": "worktree" }')).toEqual({
defaultThreadEnvMode: "worktree",
});
expect(parseT3ProjectFile('{ "initSubmodulesOnWorktreeCreate": false }')).toEqual({
initSubmodulesOnWorktreeCreate: false,
});
});

it("returns null for malformed or invalid contents", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/t3ProjectFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson);
/**
* Decode raw `t3.json` contents, treating invalid or malformed files as
* absent. Clients use this to read optional defaults (scripts, thread env
* mode) without surfacing decode errors to the user.
* mode, worktree submodule init) without surfacing decode errors to the user.
*/
export function parseT3ProjectFile(contents: string): T3ProjectFile | null {
const decoded = decodeT3ProjectFile(contents);
Expand Down
Loading