From eafa5f2b5c6081b9735535fe186e53fd6a2b5c5c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Aug 2026 22:54:59 +0200 Subject: [PATCH] fix(workspaces): resolve runtime Git portably --- ...ce-git-service.runtime.integration.test.ts | 67 +++++++++---------- .../src/server/workspace-git-service.ts | 21 ++++-- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts index 52a9abffbc..1262e2a5fe 100644 --- a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts @@ -375,14 +375,7 @@ test("selected sibling worktrees share common-ref fan-out without touching an un await eventWithin(unrelatedReady, "unrelated observer readiness"); unrelatedChanges = 0; phase = "first-change"; - const branch = await service.run({ - workspaceId: "sibling-a", - argv: ["git", "branch", "shared-ref-change"], - env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, - purpose: { kind: "git" }, - }); - branch.stdin.end(); - await expect(branch.exited).resolves.toEqual({ code: 0, signal: null }); + await createBranchThroughRuntime(service, "sibling-a", "shared-ref-change"); await Promise.all([ eventWithin(firstChanged, "first sibling ref update"), eventWithin(siblingChanged, "second sibling ref update"), @@ -391,27 +384,13 @@ test("selected sibling worktrees share common-ref fan-out without touching an un await service.pause("sibling-a"); phase = "after-pause-change"; - const branchAfterPause = await service.run({ - workspaceId: "sibling-b", - argv: ["git", "branch", "shared-ref-after-owner-pause"], - env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, - purpose: { kind: "git" }, - }); - branchAfterPause.stdin.end(); - await expect(branchAfterPause.exited).resolves.toEqual({ code: 0, signal: null }); + await createBranchThroughRuntime(service, "sibling-b", "shared-ref-after-owner-pause"); await eventWithin(siblingChangedAfterOwnerPause, "sibling ref update after owner pause"); expect(unrelatedChanges).toBe(0); await service.destroy("sibling-a"); phase = "after-destroy-change"; - const branchAfterDestroy = await service.run({ - workspaceId: "sibling-b", - argv: ["git", "branch", "shared-ref-after-owner-destroy"], - env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, - purpose: { kind: "git" }, - }); - branchAfterDestroy.stdin.end(); - await expect(branchAfterDestroy.exited).resolves.toEqual({ code: 0, signal: null }); + await createBranchThroughRuntime(service, "sibling-b", "shared-ref-after-owner-destroy"); await eventWithin(siblingChangedAfterOwnerDestroy, "sibling ref update after owner destroy"); expect(unrelatedChanges).toBe(0); @@ -508,14 +487,17 @@ async function createBranchThroughRuntime( workspaceId: string, branch: string, ): Promise { - const process = await service.run({ + const runtime = await service.bind(workspaceId); + const gitExecutable = await runtime.resolveCommand("git"); + expect(gitExecutable).not.toBeNull(); + const childProcess = await service.run({ workspaceId, - argv: ["git", "branch", branch], - env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, + argv: [gitExecutable!, "branch", branch], + env: { PATH: process.env.PATH ?? "" }, purpose: { kind: "git" }, }); - process.stdin.end(); - await expect(process.exited).resolves.toEqual({ code: 0, signal: null }); + childProcess.stdin.end(); + await expect(childProcess.exited).resolves.toEqual({ code: 0, signal: null }); } test("selected workspace Git reads and mutations stay inside its command runtime", async () => { @@ -708,10 +690,14 @@ test("selected commit history highlighting never reads a deleted file from the h workspaceId: "history-workspace", cwd: hostDecoy, }); - const hostWriter = spawn("/bin/sh", ["-c", "printf 'host trap\\n' > deleted.ts"], { - cwd: hostDecoy, - stdio: "ignore", - }); + const hostWriter = spawn( + process.execPath, + [ + "-e", + "setTimeout(() => require('node:fs').writeFileSync('deleted.ts', 'host trap\\\\n'), 60000)", + ], + { cwd: hostDecoy, stdio: "ignore" }, + ); const file = await selectedGit.getCommitFileDiff({ sha: deletionSha, path: "deleted.ts" }); await new Promise((resolve) => setImmediate(resolve)); @@ -778,9 +764,16 @@ test("selected workspaces with the same public cwd keep Git state, mutations, an path: "tracked.txt", contents: Buffer.from("runtime a dirty\n"), }); + const runtimeA = await workspaceRuntime.bind("same-cwd-a"); + const nodeExecutable = await runtimeA.resolveCommand("node"); + expect(nodeExecutable).not.toBeNull(); const createUntracked = await workspaceRuntime.run({ workspaceId: "same-cwd-a", - argv: ["/bin/sh", "-c", "printf 'runtime a untracked\\n' > selected-proof.txt"], + argv: [ + nodeExecutable!, + "-e", + "require('node:fs').writeFileSync('selected-proof.txt', 'runtime a untracked\\\\n')", + ], env: {}, purpose: { kind: "git" }, }); @@ -805,7 +798,11 @@ test("selected workspaces with the same public cwd keep Git state, mutations, an expect(JSON.stringify(diffB)).not.toContain("runtime a dirty"); const removeUntracked = await workspaceRuntime.run({ workspaceId: "same-cwd-a", - argv: ["/bin/rm", "selected-proof.txt"], + argv: [ + nodeExecutable!, + "-e", + "require('node:fs').rmSync('selected-proof.txt', { force: true })", + ], env: {}, purpose: { kind: "git" }, }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 518b153e5c..6e8eaa803d 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -3877,13 +3877,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } +const runtimeGitExecutables = new WeakMap>(); + +function resolveRuntimeGitExecutable(runtime: BoundWorkspaceRuntime): Promise { + const existing = runtimeGitExecutables.get(runtime); + if (existing) return existing; + const resolving = runtime.resolveCommand("git").then((executable) => { + if (!executable) throw new Error("Git is not available in the workspace runtime"); + return executable; + }); + runtimeGitExecutables.set(runtime, resolving); + void resolving.catch(() => runtimeGitExecutables.delete(runtime)); + return resolving; +} + async function runRuntimeGitCommand( runtime: BoundWorkspaceRuntime, args: string[], options: GitCommandOptions, ): Promise { + const gitExecutable = await resolveRuntimeGitExecutable(runtime); const process = await runtime.run({ - argv: ["git", "-c", "core.quotepath=false", ...args], + argv: [gitExecutable, "-c", "core.quotepath=false", ...args], env: buildRuntimeGitEnvironment(options), purpose: { kind: "git" }, }); @@ -3936,9 +3951,7 @@ function buildRuntimeGitEnvironment(options: GitCommandOptions): Readonly