Proposal Details
Forking a module is painful, because of intra-module import paths.
If I fork github.com/upstream/foo as github.com/josharian/foo, and I still want things to just work, I must not only change the go.mod module name, but I must also change every single import of github.com/upstream/foo/PACKAGE/PATH to be github.com/josharian/foo/PACKAGE/PATH.
This is a drag. Today, I need a two or three line change in a module. It should be trivial, a thing one just does, but instead will generate over 10x that in unrelated changes.
I have a simple proposal to ease this situation: in go.mod, as a special case, allow for replace directives to do a version-unqualified replacement to the current module.
An example will make this clearer.
-- go.mod --
module github.com/josharian/foo
-- p/p.go
package p
-- x.go --
package x
import _ "github.com/upstream/foo/p"
This does not build today, and illustrates the problem: I must change the import to github.com/josharian/foo/p for it to compile.
I propose that adding this line to go.mod should work:
replace github.com/upstream/foo => github.com/josharian/foo
This says: While compiling this module, when you see github.com/upstream/foo, get it from github.com/josharian/foo. And github.com/josharian/foo is right at hand.
If this worked, then forking a module would be exactly two changes, a module change and a replace directive.
Right now doing this yields this error message:
go.mod:5: replacement module without version must be directory path (rooted or starting with . or ..)
So I believe this should be fully backwards compatible.
Comment From: seankhliao
I don't think this should be allowed, it makes the situation much more confusing as each package can now be referred to by multiple different names.
This can also be abused to create a mess of unimportable dependencies: you "fork" a module named "mod" (or some other short name because you want convenience when working on the local module), some other module imports your module, but also wants to do the same thing. "mod" now refers to that some other module, rather than the original module.
Comment From: josharian
you "fork" a module named "mod"
I would argue that this is the problem here. If it hurts, don't do it.
The goal here is to make what should be a trivial task--fork, fix a minor bug, use it. That's currently so painful I live with bugs in dependencies that I could fix in a heartbeat. Or I vendor code, adding unnecessary weight. Or I end up with non-upstreamable, unmaintainable forks.
each package can now be referred to by multiple different names
I'd also be down with a new rename directive that outlaws the original import path. (Or just add those semantics to this special case?)
Comment From: mateusz834
Personally, I don’t mind rewriting the entire repository (including go.mod and all imports), as it’s easy to automate using the go/* packages. However, what’s really annoying is that when you merge upstream changes, you often end up with numerous merge conflicts just in the imports (I’m planning to write a tool that can automatically resolve these merge conflicts at some point). This is why i could see this really helpful.
Comment From: gabyhelp
Related Issues
- proposal: cmd/go: `go get` automatically add `replace` in go.mod #39185
- proposal: module system: improve replace directive compatibility with forked v2 modules #70316 (closed)
- affected/package: cmd/go allow renaming a module without replacing all import paths #59766 (closed)
- proposal: cmd/go: permit replace in go.mod to act as path alias #35382 (closed)
- proposal: cmd/go: allow replacing a subdirectory within a package #30886 (closed)
- proposal: go.mod's replace in .go import section, not in go.mod #37743 (closed)
- force location of package in go.mod #27253 (closed)
- cmd/go: easier go.mod updates when packages are in the same repository #50698 (closed)
- proposal: cmd/go: support `local` directive in go.mod #51779 (closed)
Related Code Changes
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
Comment From: ldemailly
If we do improve on this situation (contributing a fix and using it while it gets merged) which I hope we do, specially given the sometime glacial pace at which PRs get merged:
We should also address go installability.
Comment From: jimmyfrasche
I think in general it would be good to just not repeat the module path in local imports. Maybe say that an import that starts with a / is local to the current module. Externally you'd need import "github.com/mod/v6/foo" but internally you'd just need import "/foo". Forking a module that used locally rooted paths like that would just need an update to go.mod. And, forking aside, it's less to write and when reading it's immediately obvious whether a package is in the same module.
Comment From: seankhliao
relative import paths were rejected before #20883 , I believe that includes module relative paths.
Comment From: josharian
Most of that discussion pre-dates modules. And the emoji voting appears to me to have been overwhelmingly in favor of there being a problem, in particular with forks. Ian’s comment closing it doesn’t address that issue at all.
I’m not too particular about the mechanism…just that there be one.
Comment From: ldemailly
I guess ./ would be more correct than / as root (unless thinking of / as a path in a chroot of the module)
But mostly the how is indeed less important than the why: minimal changes to code to be able to fork, patch, submit upstream and yet use the changed code. (including go install)
Comment From: cespare
I really like @jimmyfrasche's idea (also mentioned by several people on #20883). I think it's a nice readability improvement in the module age. If you see an import like
"github.com/someproj/abc/xyz"
what it means to you is pretty different depending on whether the code happens to be in the github.com/someproj/abc module or not. (Is it "pulling in a sibling package" or "importing third-party code from github"?) Differentiating these is great.
In the module-relative / world, imports would be more clearly divided into their three types (stdlib, third-party, module):
import (
"flag"
"os"
"golang.org/x/sync/errgroup"
"rsc.io/omap"
"/db/dbproto"
"/timeutil"
)
20883 has some issues, mainly stemming from the fact that the actual original proposal was for true relative imports: the import path could be resolved as a file path relative to the current package directory. That is a whole can of worms and (IMO) definitely not something we should do. @jimmyfrasche's idea, which could be called "module-relative import paths" or "module-rooted import paths", seems very constrained and solves a few specific problems nicely.
Comment From: rittneje
Wouldn't this rely on the original module author having used this new module-relative import style? If they don't, you still have to rewrite all the imports upon forking. And it also seems like a huge amount of churn through the ecosystem for every existing module to make itself forkable. On that note, what about goimports - wouldn't it need to enforce module-relative imports?
Another concern I have is that today I can put a third-party module into the vendor folder, and still be able to compile my application in GOPATH mode. However, it is unclear whether that would work if the module starts to use module-relative imports. Would the toolchain be able to handle them in that case?
Turning back to the original problem statement, I feel like the problem stems from having to change the module directive in the fork's go.mod in the first place. I feel like it would be reasonable for the toolchain to say that if the main module says replace github.com/upstream/foo => github.com/josharian/foo, then the go.mod in github.com/josharian/foo is allowed to say module github.com/upstream/foo. Would that resolve the issue? (Another benefit of this is the only change you have to make to the fork is whatever change you wanted to make in the first place.)
Comment From: cespare
Wouldn't this rely on the original module author having used this new module-relative import style? If they don't, you still have to rewrite all the imports upon forking.
Yes. But presumably active codebases would adjust to this pretty quickly (using an updated goimports, as you mentioned).
And it also seems like a huge amount of churn through the ecosystem for every existing module to make itself forkable.
The number of lines changed is large, but the changes themselves are trivial and mechanical.
Another concern I have is that today I can put a third-party module into the vendor folder, and still be able to compile my application in GOPATH mode.
I don't think that tooling improvements should be constrained by GOPATH mode.
One simple solution is to say that module-rooted imports only work inside modules.
Comment From: thepudds
FWIW, my understanding is that because GOPATH mode has no place to record a go version directive (because there is no go.mod file), GOPATH mode defaults to a language version of Go 1.21, which is before the loop variable semantics change in Go 1.22. If that is true, it might be fairly risky to take third-party code that is actively developed today (or was actively developed against Go 1.22 or higher) and run it in GOPATH mode.
Comment From: rittneje
@thepudds That is not so. GOPATH mode uses 1.22 loop semantics.
@josharian I just ran a test where I made a fork of a GitHub repo, made changes to it but did NOT edit its go.mod at all, and then targeted it from my main module's go.mod via a replace directive. This worked perfectly fine. Can you elaborate on why you have been editing the go.mod file in your fork?
Comment From: josharian
@rittneje was your replace directive local (i.e. a file path)? that doesn't work for sharing with the rest of the world and having it just work. there's lots of relevant discussion in https://github.com/golang/go/issues/20883.
Comment From: rittneje
No, the replace directive pointed to the GitHub URL of my fork.
Comment From: rittneje
In case anyone wants to see it:
go.mod
module gomodtest
go 1.24.6
replace github.com/go-jose/go-jose/v4 => github.com/rittneje/go-jose/v4 v4.0.0-20250815213945-92d970eb79e0
require github.com/go-jose/go-jose/v4 v4.1.2
main.go
package main
import (
"fmt"
"github.com/go-jose/go-jose/v4/jwt"
)
func main() {
fmt.Println(jwt.IsThisTheFork())
}
(The choice of go-jose for this test was completely arbitrary.)