fix: moduleroot() produces a corrupt root in Go workspace mode#73
Merged
Conversation
… mode Signed-off-by: Steve Coffman <steve@khanacademy.org>
Signed-off-by: Steve Coffman <steve@khanacademy.org>
Contributor
Author
|
@anitgandhi if you get a chance, I would appreciate it if you could take a look |
anitgandhi
approved these changes
May 28, 2026
Contributor
|
approach makes sense, thanks for submitting the fix! |
bts-bastion
added a commit
to bastionplatforms/gta
that referenced
this pull request
Jun 16, 2026
Ported from the fork's go.work support work: verifies that in a Go workspace, a change in one module marks dependent packages in sibling modules, an isolated module reports only itself, and a go.work change marks all workspace packages. These pass against upstream's single-root workspace handling (PR digitalocean#73) without any multi-root machinery; gta relies on packages.Load workspace awareness for cross-module resolution.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix:
moduleroot()produces a corrupt root in Go workspace modeSummary
gta.go'smoduleroot()function runsgo list -m -f '{{.Dir}}'to find themodule root directory. In Go workspace mode (when a
go.workfile is present),this command outputs one directory per workspace module rather than one. The
function then calls
strings.TrimSpace()on the combined output and returns theresult as a single string, producing a multi-line value that is not a valid
filesystem path.
The bug is semi-benign in that it does not always prevent
gtafrom producingcorrect output, but leaves
gta.rootsin a corrupt state, removingthe workspace root from the set of known roots and causing the "is this the module
root?" early-exit in
isIgnoredByGoto never fire.Affected code
File:
gta.goRoot cause
go list -m -f '{{.Dir}}'behaves differently depending on context:go.workstrings.TrimSpacestrips leading and trailing whitespace but leaves internalnewlines intact. In a workspace with 19 modules, the returned string is
1,000+ characters containing 18 embedded newlines, none of which is a valid
filesystem path.
Concrete example with
workspace-mono(19 modules):This gets stored as
gta.roots[0]. Each later call toisIgnoredByGo(path, gta.roots)compares the corrupt string against realfilesystem paths. The comparison always fails, and the early-exit path (which
short-circuits the recursion when the path is the module root itself) never fires.
Why the Bug Is Semi-Benign (Latent) Today
isIgnoredByGohas two layers:Root check (early exit): if the path exactly equals a root, return
false(not ignored). This is the layer that the corrupt root breaks.
Base-name check: if
filepath.Base(path)starts with.or_, or equals"testdata", returntrue(ignored). The corrupt root does not affect thislayer. It handles all real cases correctly.
Because the base-name checks do the substantive work, the corrupt root only removes
an optimization. The function produces correct results regardless.
The bug would become harmful if
gtagained use ofrootsbeyond the early-exitin
isIgnoredByGo— for example, to bound the upward walk indeepestUnignoredDir, or to restrict which modules the dependency graph loads.Today, neither applies.
Fix: Separate the Pure Computation from the I/O
workspaceroot()does two distinct things: it spawns a child process (I/O) andparses the output into a directory path (pure computation). Keeping these separate
makes both independently testable without running a real
gotoolchain.The pure core is
parseGOWORK(output string) (string, bool). The I/O shell,workspaceroot(), runsgo env GOWORK, passes the output toparseGOWORK, andreturns the result.
toplevel()callsworkspaceroot()and falls back tomoduleroot()in the non-workspace case.gta.go— changesNo changes to
moduleroot()are needed.options.go— addSetRootsThe existing test suite uses
SetDiffer,SetPackager, andSetPrefixestoinject behavior via
Optionfunctions. AddingSetRootsfollows the same patternand lets tests (and callers) inject a root directly without going through
toplevel().This requires a small guard in
New()wheretoplevel()is called.We should only call it when
g.rootsis still nil after applying all options.Test plan
Unit tests for
parseGOWORK(pure function, no I/O)Because
parseGOWORKhas no I/O, table-driven tests cover it exhaustively inparallel:
Integration tests for
toplevel()viaSetRootstoplevel()callsexec.Command("go", "env", "GOWORK"), which requires a real Go toolchain and reads the actual environment. Do not uset.SetEnvhere — it turns offt.Parallel(). Instead, uset.Setenv, which is safe for parallel tests in Go 1.17+, or bypasstoplevel()altogether by testing throughNew()with
SetRoots.The
SetRootsoption provides a clean seam for testing the downstream effects ofroot injection without spawning child processes:
For
workspaceroot(), combine all cases into one table-driven test. The threecases (active workspace,
GOWORK=off, no workspace) map cleanly to a table.t.TempDir()at the parent level provides a real path for the active casewithout requiring a real file on disk:
Note:
t.Setenv(nott.SetEnv) is used here. It restores the original value after the test completes and is safe in parallel test cases because it does not turn offt.Parallel()at the parent level.Test helper discipline
Any helper function shared by more than one test case must call
t.Helper()as its first statement so that failure output points to the call site, not the helper: