From fc48826f86f065aa4a92bfa06232c15a3010eb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Apr 2026 16:44:05 +0200 Subject: [PATCH] fix(server): preserve selected workspace root (#361) Fixes #202 ## Summary - keep the default `root` worktree directory pointed at the folder the user opened - continue using the git repo root only for git/worktree discovery - add a targeted regression test for opening a repo subfolder as the workspace ## Why When a workspace is opened from a subfolder inside a git repo, CodeNomad currently maps the `root` worktree to the repo root. That causes proxied OpenCode requests to run with the repo root directory and miss an `opencode.json` that lives in the selected subfolder. ## Validation - inspected the attached `config-issue.zip` from #202 - confirmed `resolveRepoRoot(proj-1)` still returns the git root while `listWorktrees()` now returns `root.directory = proj-1` - `npx tsx --test "packages/server/src/workspaces/__tests__/git-worktrees.test.ts"` - `npm run typecheck --workspace @neuralnomads/codenomad` --- .../__tests__/git-worktrees.test.ts | 47 +++++++++++++++++++ .../server/src/workspaces/git-worktrees.ts | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/workspaces/__tests__/git-worktrees.test.ts diff --git a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts new file mode 100644 index 00000000..a725637b --- /dev/null +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import { listWorktrees } from "../git-worktrees" + +describe("listWorktrees", () => { + it("uses the selected workspace folder for the root worktree directory", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-")) + const binDir = path.join(temp, "bin") + const repoRoot = path.join(temp, "repo") + const workspaceFolder = path.join(repoRoot, "proj-1") + const originalPath = process.env.PATH + + try { + mkdirSync(binDir, { recursive: true }) + mkdirSync(workspaceFolder, { recursive: true }) + + const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git") + const porcelain = [ + `worktree ${repoRoot}`, + "HEAD 1111111", + "branch refs/heads/main", + "", + ].join("\\n") + + if (process.platform === "win32") { + writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`) + } else { + writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 }) + } + + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}` + + const worktrees = await listWorktrees({ repoRoot, workspaceFolder }) + + assert.equal(worktrees[0]?.slug, "root") + assert.equal(worktrees[0]?.directory, workspaceFolder) + assert.equal(worktrees[0]?.kind, "root") + assert.notEqual(worktrees[0]?.directory, repoRoot) + } finally { + process.env.PATH = originalPath + rmSync(temp, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index 9e53e305..08700901 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -105,7 +105,7 @@ export async function listWorktrees(params: { const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) if (!result.ok) { - const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" } + const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, kind: "root" } logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") return [rootDescriptor] } @@ -114,7 +114,7 @@ export async function listWorktrees(params: { const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot)) const rootDescriptor: WorktreeDescriptor = { slug: "root", - directory: repoRoot, + directory: workspaceFolder, kind: "root", branch: rootRecord?.branch, }