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`
This commit is contained in:
Pascal André
2026-04-26 16:44:05 +02:00
committed by GitHub
parent 2c7b81f812
commit fc48826f86
2 changed files with 49 additions and 2 deletions

View File

@@ -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 })
}
})
})

View File

@@ -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,
}