authorgravatar for rodrigobrechard@gmail.comRodrigo Brechard <rodrigobrechard@gmail.com> 2026-08-24 11:54:14+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 02:54:14-07:00
logbe3da50e96bb6700fbd111e061d8071a04427fb0
tree10e5f3957fbd4e830ff99d82537ca3317bbe704e
parentf9a726e6231d76124e7210492745a40380c74df3
signature Signed by PGP key B5690EEEBB952194

fix(server): check out submodules in a new worktree (#7674)

Co-authored-by: Rodrigo Brechard <rodrigo@clubtidy.fr>

2 files changed, 110 insertions(+), 0 deletions(-)

apps/server/src/vcs/GitVcsDriverCore.test.ts+86
...@@ -1374,6 +1374,92 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1374,6 +1374,92 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1374 }),1374 }),
1375 );1375 );
13761376
1377 it.effect("checks out submodules in a new worktree", () =>
1378 Effect.gen(function* () {
1379 const fileSystem = yield* FileSystem.FileSystem;
1380 const pathService = yield* Path.Path;
1381
1382 // Git refuses `file:` submodule transports by default (CVE-2022-39253)
1383 // and ignores repo-level config for it, so a local fixture needs the
1384 // env allowance. Real submodules are https/ssh and need none of this.
1385 const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL;
1386 process.env.GIT_ALLOW_PROTOCOL = "file";
1387 yield* Effect.addFinalizer(() =>
1388 Effect.sync(() => {
1389 if (previousAllowedProtocol === undefined) {
1390 delete process.env.GIT_ALLOW_PROTOCOL;
1391 } else {
1392 process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol;
1393 }
1394 }),
1395 );
1396
1397 // A real submodule: `git worktree add` leaves these empty, which is
1398 // what silently strips shared tooling out of every new worktree.
1399 const submoduleRepo = yield* makeTmpDir("git-submodule-");
1400 yield* initRepoWithCommit(submoduleRepo);
1401 yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n");
1402 yield* git(submoduleRepo, ["add", "."]);
1403 yield* git(submoduleRepo, ["commit", "-m", "shared"]);
1404
1405 const cwd = yield* makeTmpDir();
1406 const { initialBranch } = yield* initRepoWithCommit(cwd);
1407 yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]);
1408 yield* git(cwd, ["commit", "-m", "add submodule"]);
1409
1410 const worktreePath = pathService.join(
1411 yield* makeTmpDir("git-worktrees-"),
1412 "submodule-worktree",
1413 );
1414 const driver = yield* GitVcsDriver.GitVcsDriver;
1415 yield* driver.createWorktree({
1416 cwd,
1417 path: worktreePath,
1418 refName: initialBranch,
1419 newRefName: "feature/submodules",
1420 });
1421
1422 assert.equal(
1423 yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")),
1424 true,
1425 );
1426 }),
1427 );
1428
1429 it.effect("still creates the worktree when submodule checkout fails", () =>
1430 Effect.gen(function* () {
1431 const fileSystem = yield* FileSystem.FileSystem;
1432 const pathService = yield* Path.Path;
1433
1434 const cwd = yield* makeTmpDir();
1435 const { initialBranch } = yield* initRepoWithCommit(cwd);
1436 // Points at a repository that does not exist, so the checkout fails the
1437 // way an unreachable private remote would. Creation must still succeed.
1438 yield* writeTextFile(
1439 cwd,
1440 ".gitmodules",
1441 '[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n',
1442 );
1443 yield* git(cwd, ["add", "."]);
1444 yield* git(cwd, ["commit", "-m", "add unreachable submodule"]);
1445
1446 const worktreePath = pathService.join(
1447 yield* makeTmpDir("git-worktrees-"),
1448 "broken-submodule-worktree",
1449 );
1450 const driver = yield* GitVcsDriver.GitVcsDriver;
1451 const created = yield* driver.createWorktree({
1452 cwd,
1453 path: worktreePath,
1454 refName: initialBranch,
1455 newRefName: "feature/broken-submodules",
1456 });
1457
1458 assert.equal(created.worktree.path, worktreePath);
1459 assert.equal(yield* fileSystem.exists(worktreePath), true);
1460 }),
1461 );
1462
1377 it.effect("creates and removes a worktree for a new refName", () =>1463 it.effect("creates and removes a worktree for a new refName", () =>
1378 Effect.gen(function* () {1464 Effect.gen(function* () {
1379 const cwd = yield* makeTmpDir();1465 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+24
...@@ -2776,6 +2776,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2776,6 +2776,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2776 timeoutMs: WORKTREE_ADD_TIMEOUT_MS,2776 timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
2777 });2777 });
27782778
2779 // `git worktree add` leaves submodules empty, so a repo that keeps agent
2780 // skills, tooling or source in one gets a worktree that is quietly missing
2781 // them. Best-effort: the objects are usually already in the parent's
2782 // `.git/modules`, but a first-ever clone needs the network, and failing to
2783 // populate a submodule must not roll back the caller's thread.
2784 const hasSubmodules = yield* fileSystem
2785 .exists(path.join(worktreePath, ".gitmodules"))
2786 .pipe(Effect.orElseSucceed(() => false));
2787 if (hasSubmodules) {
2788 yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [
2789 "submodule",
2790 "update",
2791 "--init",
2792 "--recursive",
2793 ]).pipe(
2794 Effect.catch((cause) =>
2795 Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", {
2796 worktreePath,
2797 cause,
2798 }),
2799 ),
2800 );
2801 }
2802
2779 if (input.newRefName && input.baseRefName) {2803 if (input.newRefName && input.baseRefName) {
2780 const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => []));2804 const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => []));
2781 const parsedBaseRef = parseRemoteRefWithRemoteNames(2805 const parsedBaseRef = parseRemoteRefWithRemoteNames(