What version of gazelle are you using?
0.52.2
What version of rules_go are you using?
0.61.1
What version of Bazel are you using?
9.2.0
Does this issue reproduce with the latest releases of all the above?
Yes. I built a minimal repro against current bazel-contrib/bazel-gazelle master with a real bazel build, not just by reading the code, and it reproduces there too.
What operating system and processor architecture are you using?
Linux x86_64, but this is not OS- or architecture-specific: it is a pure label-resolution issue in the go_deps Bzlmod extension.
What did you do?
We have a monorepo (github.com/DataDog/datadog-agent) that:
- declares a single Bazel module at the repo root,
- uses
go_deps.from_file(go_work = "//:go.work"), where go.work lists dozens of use () entries, each its own Go module with its own go.mod,
- has checked-in
BUILD.bazel files for every one of those in-tree modules, reachable at //<path>, produced by our own root //:gazelle invocation with a gazelle:prefix directive matching the repo's module path,
- has these
go.work members replace-ing each other locally in their own go.mod files on top of go.work. For example, one member's go.mod has a replace directive pointing another member's import path at a relative local path, and that member is in turn replaced the same way by several sibling modules' go.mod files.
Separately, an external Go module is consumed via the same go_deps extension and imports one of those same in-tree packages by its fully-qualified import path.
I built a minimal, real, bazel build-verified repro of this (not just a code reading) with the following shape:
repo/
├── MODULE.bazel # bazel_dep(gazelle), go_deps.from_file(go_work = "//:go.work")
├── go.work # use ( . ./pkg/foo )
├── go.mod # module example.com/repo
│ # require example.com/repo/pkg/foo v0.0.0-fake // indirect
│ # require example.com/thirdparty v0.0.0-fake
│ # replace example.com/repo/pkg/foo => ./pkg/foo
└── pkg/foo/
├── BUILD.bazel # go_library(name = "foo", importpath = "example.com/repo/pkg/foo", ...), checked in
├── foo.go
└── go.mod # module example.com/repo/pkg/foo
Plus a thirdparty Go module (injected hermetically via go_deps.archive_override so the repro needs no network access) whose go.mod requires example.com/repo/pkg/foo and which imports it. A go_binary at the repo root depends on both //pkg/foo and, transitively, on thirdparty.
The crucial, easy-to-miss ingredient is the replace example.com/repo/pkg/foo => ./pkg/foo line in the root's own go.mod. Without it, go_deps correctly avoids creating a duplicate repo for example.com/repo/pkg/foo. I verified this empirically too: removing that line makes the build fail differently, with an unrelated dangling-repository error rather than a duplicate-package one (see "Related, but distinct" below). With it, bazel build //... fails with:
package conflict error: example.com/repo/pkg/foo: multiple copies of package passed to linker:
@@gazelle++go_deps+com_example_repo_pkg_foo//
@@//pkg/foo
I traced why in internal/bzlmod/go_deps.bzl:
go_deps walks every module_tag derived from parsing all of the workspace's go.mod files (via deps_from_go_mod) to compute MVS resolutions. For each module_tag.path, if module_tag.path in replace_map (replace_map is the union of every replace directive found in any go.work member's go.mod, collected earlier), the resulting module_resolutions[path] struct is given local_path = replacement.local_path, but no module_name attribute.
- The "unify" loop meant to make root-owned
go.work members resolve to themselves instead of being fetched looks like this:
for path, bazel_go_module in bazel_go_modules.items():
if path in archive_overrides or path in gazelle_overrides or path in module_overrides or path in replace_map:
continue
if path in modules_from_go_work:
module_resolutions[path] = bazel_go_module
continue
The override/replace check fires before the modules_from_go_work branch below it. So if the path is also a replace_map target (as pkg/foo is here, from some other go.work member's go.mod), the loop continues without ever overwriting module_resolutions[path] with the root-owned struct. The struct from the MVS pass, carrying local_path and no module_name, survives untouched.
- At repo creation time,
if hasattr(module, "module_name"): continue is the only thing that skips declaring a go_repository for a root-owned path. Because the surviving struct has no module_name, this check does not fire, and go_deps calls go_repository(local_path = "pkg/foo", importpath = "example.com/repo/pkg/foo", ...), a second, independent go_library for source that's already tracked in-tree.
Any go_binary/go_test depending on both label spaces then fails at link time exactly as shown above.
What did you expect to see?
Since example.com/repo/pkg/foo (or, in our real case, the equivalent in-tree package) is a go.work member with its own in-tree, already-BUILD.bazel'd package, I expected go_deps to never create a second go_repository for it, regardless of whether some other go.work member's go.mod also happens to replace it. Concretely, the path in replace_map short-circuit in the unify loop should not bypass the path in modules_from_go_work unification when the path in question is a root-owned go.work member. Root ownership should take priority.
What did you see instead?
go_deps creates a second, independent go_repository (via local_path) for a go.work member whenever (a) some other module imports it and (b) any go.mod in the workspace has a replace directive targeting that same import path. In a monorepo where submodules commonly replace each other locally (as ours does, and as I'd expect most non-trivial go.work-based monorepos to, since it is the standard way to make a multi-module repo buildable both with and without go.work), this can affect any workspace member that another module happens to import. The result is two distinct Bazel labels providing the exact same Go import path from the exact same source files, and rules_go's linker correctly rejects the resulting duplicate with "multiple copies of package passed to linker."
Today we work around this in datadog-agent by hand-maintaining gazelle:resolve and gazelle:resolve_regexp directives in go_deps.gazelle_default_attributes(directives = [...]) that redirect every such import back to the in-tree label. This works, but:
- it has to be updated by hand every time a new external module starts importing one of our in-tree submodules,
- it silently stops working (falls back to the duplicate-label failure) if anyone forgets to update it,
- it does not address the case where two of our own
go.work members cross-reference each other through the same external label space.
Related, but distinct, findings from building this repro (separate bugs, not covered by the analysis above, but worth mentioning since a real fix will likely touch the same code):
- Without the
replace line above (no replace_map entry for the colliding path), the same setup fails differently. The colliding path is silently omitted from the shared bazel_gazelle_go_repository_config (filtered by if not getattr(module, "go_mod_dir", None)), so thirdparty's own Gazelle-generated BUILD.bazel ends up depending on a Bazel repo name that was never declared, and the build fails at analysis time with no such package '@@[unknown repo ...]'.
- A single-
go.mod root module (no go.work at all) whose own top-level import path is required by another go_deps-materialized module hits yet another variant: the path is kept in bazel_gazelle_go_repository_config, but under the bogus repo name "@" + module.name, which is not the module's actual canonical repo name (@@, i.e. empty). The generated consumer BUILD.bazel then references a nonexistent @@<module_name> repo and fails with Repository '@@<module_name>' is not defined.
Related, but distinct, existing reports:
What version of gazelle are you using?
0.52.2
What version of rules_go are you using?
0.61.1
What version of Bazel are you using?
9.2.0
Does this issue reproduce with the latest releases of all the above?
Yes. I built a minimal repro against current
bazel-contrib/bazel-gazellemaster with a realbazel build, not just by reading the code, and it reproduces there too.What operating system and processor architecture are you using?
Linux x86_64, but this is not OS- or architecture-specific: it is a pure label-resolution issue in the
go_depsBzlmod extension.What did you do?
We have a monorepo (
github.com/DataDog/datadog-agent) that:go_deps.from_file(go_work = "//:go.work"), wherego.worklists dozens ofuse ()entries, each its own Go module with its owngo.mod,BUILD.bazelfiles for every one of those in-tree modules, reachable at//<path>, produced by our own root//:gazelleinvocation with agazelle:prefixdirective matching the repo's module path,go.workmembersreplace-ing each other locally in their owngo.modfiles on top ofgo.work. For example, one member'sgo.modhas areplacedirective pointing another member's import path at a relative local path, and that member is in turnreplaced the same way by several sibling modules'go.modfiles.Separately, an external Go module is consumed via the same
go_depsextension and imports one of those same in-tree packages by its fully-qualified import path.I built a minimal, real,
bazel build-verified repro of this (not just a code reading) with the following shape:Plus a
thirdpartyGo module (injected hermetically viago_deps.archive_overrideso the repro needs no network access) whosego.modrequiresexample.com/repo/pkg/fooand which imports it. Ago_binaryat the repo root depends on both//pkg/fooand, transitively, onthirdparty.The crucial, easy-to-miss ingredient is the
replace example.com/repo/pkg/foo => ./pkg/fooline in the root's owngo.mod. Without it,go_depscorrectly avoids creating a duplicate repo forexample.com/repo/pkg/foo. I verified this empirically too: removing that line makes the build fail differently, with an unrelated dangling-repository error rather than a duplicate-package one (see "Related, but distinct" below). With it,bazel build //...fails with:I traced why in
internal/bzlmod/go_deps.bzl:go_depswalks everymodule_tagderived from parsing all of the workspace's go.mod files (viadeps_from_go_mod) to compute MVS resolutions. For eachmodule_tag.path, ifmodule_tag.path in replace_map(replace_mapis the union of everyreplacedirective found in any go.work member's go.mod, collected earlier), the resultingmodule_resolutions[path]struct is givenlocal_path = replacement.local_path, but nomodule_nameattribute.go.workmembers resolve to themselves instead of being fetched looks like this:modules_from_go_workbranch below it. So if the path is also areplace_maptarget (aspkg/foois here, from some other go.work member'sgo.mod), the loopcontinues without ever overwritingmodule_resolutions[path]with the root-owned struct. The struct from the MVS pass, carryinglocal_pathand nomodule_name, survives untouched.if hasattr(module, "module_name"): continueis the only thing that skips declaring ago_repositoryfor a root-owned path. Because the surviving struct has nomodule_name, this check does not fire, andgo_depscallsgo_repository(local_path = "pkg/foo", importpath = "example.com/repo/pkg/foo", ...), a second, independentgo_libraryfor source that's already tracked in-tree.Any
go_binary/go_testdepending on both label spaces then fails at link time exactly as shown above.What did you expect to see?
Since
example.com/repo/pkg/foo(or, in our real case, the equivalent in-tree package) is ago.workmember with its own in-tree, already-BUILD.bazel'd package, I expectedgo_depsto never create a secondgo_repositoryfor it, regardless of whether some other go.work member'sgo.modalso happens toreplaceit. Concretely, thepath in replace_mapshort-circuit in the unify loop should not bypass thepath in modules_from_go_workunification when the path in question is a root-ownedgo.workmember. Root ownership should take priority.What did you see instead?
go_depscreates a second, independentgo_repository(vialocal_path) for ago.workmember whenever (a) some other module imports it and (b) any go.mod in the workspace has areplacedirective targeting that same import path. In a monorepo where submodules commonlyreplaceeach other locally (as ours does, and as I'd expect most non-trivialgo.work-based monorepos to, since it is the standard way to make a multi-module repo buildable both with and withoutgo.work), this can affect any workspace member that another module happens to import. The result is two distinct Bazel labels providing the exact same Go import path from the exact same source files, andrules_go's linker correctly rejects the resulting duplicate with "multiple copies of package passed to linker."Today we work around this in
datadog-agentby hand-maintaininggazelle:resolveandgazelle:resolve_regexpdirectives ingo_deps.gazelle_default_attributes(directives = [...])that redirect every such import back to the in-tree label. This works, but:go.workmembers cross-reference each other through the same external label space.Related, but distinct, findings from building this repro (separate bugs, not covered by the analysis above, but worth mentioning since a real fix will likely touch the same code):
replaceline above (noreplace_mapentry for the colliding path), the same setup fails differently. The colliding path is silently omitted from the sharedbazel_gazelle_go_repository_config(filtered byif not getattr(module, "go_mod_dir", None)), sothirdparty's own Gazelle-generatedBUILD.bazelends up depending on a Bazel repo name that was never declared, and the build fails at analysis time withno such package '@@[unknown repo ...]'.go.modroot module (nogo.workat all) whose own top-level import path is required by anothergo_deps-materialized module hits yet another variant: the path is kept inbazel_gazelle_go_repository_config, but under the bogus repo name"@" + module.name, which is not the module's actual canonical repo name (@@, i.e. empty). The generated consumerBUILD.bazelthen references a nonexistent@@<module_name>repo and fails withRepository '@@<module_name>' is not defined.Related, but distinct, existing reports:
go.modfiles in a Bzlmod monorepo" support question, unresolved since 2023),go_deps-internal Gazelle invocation don't match what the root module expects" class of problem:repo_namemismatch rather than label duplication),bazel mod tidywrites danglinguse_repoentries for localgo.workmodules withreplace#2336 / go_deps: don't declare use_repo dependency on module from go.work #2337 (a narrower, already-fixed bug: danglinguse_repoentries for locally-replacedgo.workmembers. That fix addressedbazel mod tidybookkeeping, not repo generation, so it does not help here).