Skip to content

Commit 24f97a9

Browse files
committed
fix(syncmodutil): strip conflicting destination replaces before pinning
Per review feedback on #983: emitting a replace block without first removing pre-existing replace directives in the destination for the same module produces duplicates, causing `go mod tidy` to fail with "multiple replacements for <module>". Handle both forms in the destination: single line (`replace foo => bar v1`) and lines inside an existing `replace (...)` block. Replaces for modules the source does not touch (destination-specific overrides, such as local-path replaces) are preserved. Signed-off-by: Yi Nuo <218099172+yi-nuo426@users.noreply.github.com>
1 parent ac084c8 commit 24f97a9

1 file changed

Lines changed: 67 additions & 0 deletions

File tree

  • cmd/syncmodutil/internal/modsync

cmd/syncmodutil/internal/modsync/sync.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,23 @@ func (g *GoMod) SyncRequire(f io.Reader, throwerr bool) (gomod string, err error
9393
}
9494
}
9595

96+
// Compute the set of modules we will emit a replace directive for, so
97+
// that any pre-existing replace in the destination for the same module
98+
// can be stripped first. Leaving both in place would cause `go mod tidy`
99+
// to fail with "multiple replacements for <module>".
100+
emitModules := make(map[string]bool, len(g.RequiredVersions)+len(g.ReplacedVersions))
101+
for _, required := range g.RequiredVersions {
102+
if !replacedMods[required.Name] {
103+
emitModules[required.Name] = true
104+
}
105+
}
106+
for _, r := range g.ReplacedVersions {
107+
if len(r) >= 1 {
108+
emitModules[r[0].Name] = true
109+
}
110+
}
111+
data = stripConflictingReplaces(data, emitModules)
112+
96113
if len(g.RequiredVersions) > 0 || len(g.ReplacedVersions) > 0 {
97114
data = append(data, "replace (")
98115
}
@@ -121,6 +138,56 @@ func (g *GoMod) SyncRequire(f io.Reader, throwerr bool) (gomod string, err error
121138
return
122139
}
123140

141+
// stripConflictingReplaces removes any replace directive from the
142+
// destination go.mod lines whose "from" module is in the conflicts set.
143+
// Both forms are handled: single-line (`replace foo => bar v1`) and lines
144+
// inside an existing `replace (...)` block. Replaces for modules not in
145+
// conflicts are preserved — including destination-specific overrides that
146+
// the source does not touch. Empty replace blocks that may be left behind
147+
// are valid go.mod syntax; `go mod tidy` cleans them up.
148+
func stripConflictingReplaces(data []string, conflicts map[string]bool) []string {
149+
if len(conflicts) == 0 {
150+
return data
151+
}
152+
out := make([]string, 0, len(data))
153+
inReplaceBlock := false
154+
for _, line := range data {
155+
trim := strings.TrimSpace(line)
156+
157+
if !inReplaceBlock && (trim == "replace (" || trim == "replace(") {
158+
inReplaceBlock = true
159+
out = append(out, line)
160+
continue
161+
}
162+
if inReplaceBlock && trim == ")" {
163+
inReplaceBlock = false
164+
out = append(out, line)
165+
continue
166+
}
167+
168+
if strings.Contains(trim, "=>") {
169+
var rest string
170+
switch {
171+
case inReplaceBlock:
172+
rest = trim
173+
case strings.HasPrefix(trim, "replace "):
174+
rest = strings.TrimPrefix(trim, "replace ")
175+
default:
176+
out = append(out, line)
177+
continue
178+
}
179+
parts := strings.SplitN(rest, "=>", 2)
180+
from := strings.Fields(strings.TrimSpace(parts[0]))
181+
if len(from) >= 1 && conflicts[from[0]] {
182+
continue
183+
}
184+
}
185+
186+
out = append(out, line)
187+
}
188+
return out
189+
}
190+
124191
// formatReplaceLine renders a single replace directive. The "from" version
125192
// is optional (e.g. `foo => ../foo`); the "to" version is absent for
126193
// local-path replacements.

0 commit comments

Comments
 (0)