Skip to content

fix(lsp): start a worktree's Go language server with GOWORK=off (#521) - #542

Merged
atlas-from-plumb merged 2 commits into
mainfrom
atlas/fix-521-lsp-gowork
Sep 30, 2026
Merged

atlas-from-plumb merged 2 commits into
mainfrom
atlas/fix-521-lsp-gowork

Conversation

@atlas-from-plumb

Copy link
Copy Markdown
Collaborator

Fixes #521

Defect

In a git worktree of a Go module, under an enclosing go.work that lists the main checkout's directory for that module, gopls answers about the main checkout instead of the worktree. This is the #505 layout, e.g. plumb-ops/go.work has use ./plumb, and the worktree is at plumb/.claude/worktrees/<name>.

  • workspace_symbols returns nothing, or the main checkout's copy.
  • The worktree's files are never type-checked. A post-write pass is still labelled authoritative. I reproduced this live in this worktree before the fix: writing a file with an unused os import returned [diagnostics: authoritative post-write pass] ✓ … no new errors.

Cause

#506 makes the per-directory GOWORK=off decision for the git child (hooks), run_task and mutation_test (internal/tools/git_gowork.go). The pool never applied it when it spawned a language server. So gopls resolved the enclosing go.work, and the worktree is not a module in it.

Fix

  • Same decision, one implementation. goWorkBypass is now exported as tools.GoWorkBypass. The spawner is internal/cli/pool.go (presentation), which sits above internal/tools (application), so nothing had to move layer. internal/lsp.Supervisor only receives the env.
  • goLSPEnv (internal/cli/pool_gowork.go) applies the decision to the Go server's env, keyed on the workspace root. Only the Go server gets it, because GOWORK means nothing to other servers.
  • A GOWORK you set always wins. The decision already reads the inherited environment, [lsp.go] env (both are in the env it is handed), the go env file and $GOROOT/go.env. On top of that, a GOWORK in gopls's own env setting ([lsp.go.initialization_options] env) is honoured, because gopls applies it over its process environment.
  • Surfaced.
    • The daemon log gets pool: starting the Go language server with GOWORK=off: … with root and go_work.
    • session_start, full and brief, shows Go LSP: runs with GOWORK=off — <go.work> lists another copy of this module (set GOWORK in [lsp.go] env to override).
    • This is wired through poolEntry.goWorkOff → pool.goWorkOffFor → routingProxy.GoWorkOff → connSession.lspGoWorkOff → SessionStart.WithLSPGoWorkOff.
  • Docs (docs/configuration.md, the git-child environment section) and a CHANGELOG entry under 0.20.4 → Fixed.

Diagnostics labelling

The label now tells the truth for this layout, because the server loads the worktree's module. I did not add a separate "outside every loaded module" check. It can't be detected cleanly:

  • authoritative means the server re-published for the file after the write, and it did: gopls published an empty set for a file outside its loaded packages, with no "no active builds" message to match on.
  • Knowing which modules gopls loaded would mean reimplementing gopls's view selection in the write tools, which don't know which server or env handles a file.

With this fix the auto-detected worktree case is gone: the integration test below shows gopls now publishes the unused-import error for the worktree's file. What remains is an explicit GOWORK the user chose, which plumb honours by design.

Verification

  • Red first, on origin/main.

    • TestPoolSpawn_GoWorkOffForWorktreeUnderEnclosingGoWork failed with worktree server GOWORK = "" (set=false), want "off". The stand-in server is /bin/sh recording its own env, spawned through pool.acquireLang.
    • TestIntegration_PoolGoplsWorktreeUnderGoWork (real gopls, //go:build integration) failed with workspace/symbol SharedIssue521 from the worktree root = [.../main/a.go], want exactly [.../main/.claude/worktrees/wt/b.go]. That is the LSP in a git worktree under an enclosing go.work answers from the main checkout #521 symptom exactly.
  • Green after.

    • The integration test starts gopls through the pool for both roots. The worktree root answers from the worktree's copy. The main checkout root keeps workspace mode and answers from its own copy (the positive control).
    • Each server also publishes the unused-import diagnostic for its own copy's c.go. With GOWORK=off mutated out, the worktree half fails: no unused-import diagnostic published for …/wt/c.go.
  • Left-alone direction. TestPoolSpawn_GoWorkLeftAlone covers four cases:

    • the main checkout listed in go.work;
    • an inherited GOWORK;
    • [lsp.go] env GOWORK;
    • gopls initialization_options.env.GOWORK.

    In every case the env is untouched and the pool reports nothing. TestGoLSPEnv_OnlyTheGoServer checks that a non-Go server never gets GOWORK=off. TestSessionStart_GoWorkOffLine checks the full and brief renders, both present and absent.

  • mutation_test: 9 mutants, all KILLED.

    • internal/cli: revert GOWORK=off; drop the Go-only gate; ignore gopls's env setting; spawn with the unmodified env at the call site; stop recording goWorkOff on the entry.
    • internal/tools: drop the line from the brief render; drop it from the full render; render it when nothing was switched off.
  • CI-faithful run. GOWORK=off GOTMPDIR=$PWD/.testcache go test ./internal/tools/... ./internal/cli/... ./internal/arch/... ./internal/mcp/... ./internal/tui/... -count=1 passed. The integration tests passed with -tags integration, including the existing TestIntegration_PoolHibernateWake.

  • Lint and checks. golangci-lint run ./... reports 0 issues. make check-size check-brief check-changelog passes.

Not unit-tested: the one-line accessor glue (routingProxy.GoWorkOff, connSession.lspGoWorkOff), which follows the existing DiagMode chain.

🤖 Generated with Claude Code

In a git worktree under an enclosing go.work that lists the main
checkout's directory for the module, gopls resolved that go.work, in
which the worktree is not a module. workspace_symbols answered from the
main checkout (or not at all), and the worktree's files were never
type-checked, so a post-write diagnostics pass labelled "authoritative"
reported clean code that go build rejected.

#506 already makes this per-directory GOWORK=off decision for git hooks,
run_task and mutation_test. The pool now applies it too, keyed on the
root, when it spawns a Go language server:

- tools.GoWorkBypass (renamed from goWorkBypass) is the one decision; the
  pool lives in internal/cli, above internal/tools, so nothing moves
  layer.
- An explicit GOWORK always wins: inherited, [lsp.go] env, the go env
  file or $GOROOT/go.env (all read by the decision), and gopls's own
  `env` setting in [lsp.go] initialization_options, which gopls applies
  over its process environment.
- Only the Go server is considered; GOWORK means nothing to the others.
- The daemon log says when a server starts with GOWORK=off, and
  session_start (full and brief) shows a "Go LSP:" line naming the
  go.work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review: no blocking findings. The fix is proven by a real-gopls integration test that goes red with the #521 symptom when the fix line is reverted. Non-blocking notes are tracked for a follow-up.

@atlas-from-plumb
atlas-from-plumb enabled auto-merge (rebase) September 30, 2026 19:18

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approve after updating with main.

@atlas-from-plumb
atlas-from-plumb merged commit 93c064b into main Sep 30, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LSP in a git worktree under an enclosing go.work answers from the main checkout

2 participants