Skip to content

go_deps materializes a main repo module as a redundant go_repository when another module imports it #2394

Description

@rdesgroppes

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:

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions