From 4d001c84377102dcf6ef2e7abaab0dc426fefff6 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:55:00 +0100 Subject: [PATCH 1/3] fix(worktree): initialize submodules for new workspaces --- src/node/worktree/WorktreeManager.test.ts | 56 +++++++++++++++++++++++ src/node/worktree/WorktreeManager.ts | 39 ++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/node/worktree/WorktreeManager.test.ts b/src/node/worktree/WorktreeManager.test.ts index 9cec5dda1f..f4bfef3a3b 100644 --- a/src/node/worktree/WorktreeManager.test.ts +++ b/src/node/worktree/WorktreeManager.test.ts @@ -17,6 +17,18 @@ function initGitRepo(projectPath: string): void { execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); } +function initGitRepoWithSubmodule(projectPath: string, submoduleSourcePath: string): void { + initGitRepo(projectPath); + execSync(`git -c protocol.file.allow=always submodule add "${submoduleSourcePath}" deps/sub`, { + cwd: projectPath, + stdio: "ignore", + }); + // Local-path submodules are used only in tests; allow file transport so + // `git submodule update --init --recursive` can run inside worktrees. + execSync("git config protocol.file.allow always", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "add submodule"', { cwd: projectPath, stdio: "ignore" }); +} + function createNullInitLogger(): InitLogger { return { logStep: (_message: string) => undefined, @@ -53,6 +65,50 @@ describe("WorktreeManager constructor", () => { }); }); +describe("WorktreeManager.createWorkspace", () => { + it("initializes submodules in the created worktree", async () => { + const rootDir = await fsPromises.realpath( + await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-manager-create-")) + ); + + try { + const submoduleSourcePath = path.join(rootDir, "submodule-source"); + await fsPromises.mkdir(submoduleSourcePath, { recursive: true }); + initGitRepo(submoduleSourcePath); + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + initGitRepoWithSubmodule(projectPath, submoduleSourcePath); + + const srcBaseDir = path.join(rootDir, "src"); + await fsPromises.mkdir(srcBaseDir, { recursive: true }); + + const manager = new WorktreeManager(srcBaseDir); + const initLogger = createNullInitLogger(); + + const createResult = await manager.createWorkspace({ + projectPath, + branchName: "feature_with_submodule", + trunkBranch: "main", + initLogger, + }); + expect(createResult.success).toBe(true); + if (!createResult.success || !createResult.workspacePath) return; + + const submoduleStatus = execSync("git submodule status", { + cwd: createResult.workspacePath, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + + expect(submoduleStatus.startsWith("-")).toBe(false); + } finally { + await fsPromises.rm(rootDir, { recursive: true, force: true }); + } + }, 20_000); +}); + describe("WorktreeManager.deleteWorkspace", () => { it("deletes non-agent branches when removing worktrees (force)", async () => { const rootDir = await fsPromises.realpath( diff --git a/src/node/worktree/WorktreeManager.ts b/src/node/worktree/WorktreeManager.ts index bf0f8c8536..b9d560de5b 100644 --- a/src/node/worktree/WorktreeManager.ts +++ b/src/node/worktree/WorktreeManager.ts @@ -115,6 +115,10 @@ export class WorktreeManager { initLogger.logStep("Syncing .muxignore files..."); await syncMuxignoreFiles(projectPath, workspacePath); + // Initialize/update submodules in the workspace so worktree mode matches + // the initial project clone behavior users expect. + await this.initializeSubmodules(workspacePath, initLogger, noHooksEnv); + // For existing branches, fast-forward to latest origin (best-effort) // Only if local can fast-forward (preserves unpushed work) if (shouldUseOrigin && branchExists) { @@ -225,6 +229,41 @@ export class WorktreeManager { } } + /** + * Initialize submodules for newly created workspaces. + * Best-effort: workspace creation should still succeed when submodule auth or + * network setup is unavailable. + */ + private async initializeSubmodules( + workspacePath: string, + initLogger: InitLogger, + noHooksEnv?: { env: Record } + ): Promise { + try { + initLogger.logStep("Initializing git submodules..."); + using submoduleProc = execFileAsync( + "git", + [ + "-c", + "protocol.file.allow=always", + "-C", + workspacePath, + "submodule", + "update", + "--init", + "--recursive", + ], + noHooksEnv + ); + await submoduleProc.result; + initLogger.logStep("Git submodules initialized"); + } catch (error) { + initLogger.logStderr( + `Note: Failed to initialize git submodules (${getErrorMessage(error)}), continuing` + ); + } + } + async renameWorkspace( projectPath: string, oldName: string, From ab8b566088357e4c327814670561e1d16d088723 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:15:19 +0100 Subject: [PATCH 2/3] fix(worktree): init submodules after fast-forward --- src/node/worktree/WorktreeManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/node/worktree/WorktreeManager.ts b/src/node/worktree/WorktreeManager.ts index b9d560de5b..1c97572055 100644 --- a/src/node/worktree/WorktreeManager.ts +++ b/src/node/worktree/WorktreeManager.ts @@ -115,16 +115,16 @@ export class WorktreeManager { initLogger.logStep("Syncing .muxignore files..."); await syncMuxignoreFiles(projectPath, workspacePath); - // Initialize/update submodules in the workspace so worktree mode matches - // the initial project clone behavior users expect. - await this.initializeSubmodules(workspacePath, initLogger, noHooksEnv); - // For existing branches, fast-forward to latest origin (best-effort) // Only if local can fast-forward (preserves unpushed work) if (shouldUseOrigin && branchExists) { await this.fastForwardToOrigin(workspacePath, trunkBranch, initLogger, noHooksEnv); } + // Initialize/update submodules after any fast-forward because FF can + // update submodule gitlinks without checking out the new submodule commits. + await this.initializeSubmodules(workspacePath, initLogger, noHooksEnv); + return { success: true, workspacePath }; } catch (error) { return { From 8e502518c2a8de5e6d57a00471a537ffbd64ee86 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:13:12 +0100 Subject: [PATCH 3/3] fix(worktree): avoid forcing file submodule transport Session-Id: 2fb58883-0079-4edc-8591-eb6e1d4f82cb --- src/node/worktree/WorktreeManager.test.ts | 47 ++++++++++++++--------- src/node/worktree/WorktreeManager.ts | 11 +----- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/node/worktree/WorktreeManager.test.ts b/src/node/worktree/WorktreeManager.test.ts index f4bfef3a3b..597e3925c0 100644 --- a/src/node/worktree/WorktreeManager.test.ts +++ b/src/node/worktree/WorktreeManager.test.ts @@ -23,9 +23,6 @@ function initGitRepoWithSubmodule(projectPath: string, submoduleSourcePath: stri cwd: projectPath, stdio: "ignore", }); - // Local-path submodules are used only in tests; allow file transport so - // `git submodule update --init --recursive` can run inside worktrees. - execSync("git config protocol.file.allow always", { cwd: projectPath, stdio: "ignore" }); execSync('git commit -m "add submodule"', { cwd: projectPath, stdio: "ignore" }); } @@ -86,23 +83,35 @@ describe("WorktreeManager.createWorkspace", () => { const manager = new WorktreeManager(srcBaseDir); const initLogger = createNullInitLogger(); - const createResult = await manager.createWorkspace({ - projectPath, - branchName: "feature_with_submodule", - trunkBranch: "main", - initLogger, - }); - expect(createResult.success).toBe(true); - if (!createResult.success || !createResult.workspacePath) return; + // Test-only: allow local-path submodule transport for temp fixture repos. + const previousAllowProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; - const submoduleStatus = execSync("git submodule status", { - cwd: createResult.workspacePath, - stdio: ["ignore", "pipe", "ignore"], - }) - .toString() - .trim(); - - expect(submoduleStatus.startsWith("-")).toBe(false); + try { + const createResult = await manager.createWorkspace({ + projectPath, + branchName: "feature_with_submodule", + trunkBranch: "main", + initLogger, + }); + expect(createResult.success).toBe(true); + if (!createResult.success || !createResult.workspacePath) return; + + const submoduleStatus = execSync("git submodule status", { + cwd: createResult.workspacePath, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + + expect(submoduleStatus.startsWith("-")).toBe(false); + } finally { + if (previousAllowProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowProtocol; + } + } } finally { await fsPromises.rm(rootDir, { recursive: true, force: true }); } diff --git a/src/node/worktree/WorktreeManager.ts b/src/node/worktree/WorktreeManager.ts index 1c97572055..9f61582ee0 100644 --- a/src/node/worktree/WorktreeManager.ts +++ b/src/node/worktree/WorktreeManager.ts @@ -243,16 +243,7 @@ export class WorktreeManager { initLogger.logStep("Initializing git submodules..."); using submoduleProc = execFileAsync( "git", - [ - "-c", - "protocol.file.allow=always", - "-C", - workspacePath, - "submodule", - "update", - "--init", - "--recursive", - ], + ["-C", workspacePath, "submodule", "update", "--init", "--recursive"], noHooksEnv ); await submoduleProc.result;