Skip to content

Commit 23472ca

Browse files
authored
Allow workspaces adding new module(s) with no module-specifc breaking configurations to run buf breaking (#3864)
1 parent fae9f4e commit 23472ca

File tree

15 files changed

+231
-19
lines changed

15 files changed

+231
-19
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- Promote `buf beta stats` to `buf stats`.
66
- Update built-in Well-Known Types to Protobuf v31.1.
77
- Add `buf registry sdk info` command.
8+
- Allow workspaces that are adding new module(s) with no module-specific breaking configurations
9+
to run `buf breaking`, ignoring new module(s).
810

911
## [v1.54.0] - 2025-05-12
1012

private/buf/cmd/buf/command/breaking/breaking.go

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"maps"
22+
"slices"
2123

2224
"buf.build/go/app/appcmd"
2325
"buf.build/go/app/appext"
@@ -262,17 +264,28 @@ func run(
262264
}
263265
}
264266
if len(imageWithConfigs) != len(againstImages) {
265-
// If workspaces are being used as input, the number
266-
// of images MUST match. Otherwise the results will
267-
// be meaningless and yield false positives.
267+
// In the case where the input and against workspaces do not contain the same number of
268+
// images, this could happen if the input contains new module(s). However, we require
269+
// the number of images to match because of module-specific [bufconfig.BreakingConfig].
270+
// This can result in a less satisfying UX when adding modules to a workspace.
268271
//
269-
// And similar to the note above, if the roots change,
270-
// we're torched.
271-
return fmt.Errorf(
272-
"input contained %d images, whereas against contained %d images",
273-
len(imageWithConfigs),
274-
len(againstImages),
275-
)
272+
// To mitigate this for users adding new modules to their workspace, for the case where
273+
// len(imageWithConfigs) > len(againstImages), if all modules in [imageWithConfigs] have
274+
// the same [bufconfig.BreakingConfig] (so no unique, module-specific [bufconfig.BreakingConfig]),
275+
// we query the [againstImages] for the matching modules and ignore any modules from
276+
// [imageWithConfigs] that are not found in [againstImages].
277+
//
278+
// In the case where len(imageWithConfigs) < len(againstImages) or there are module-specific
279+
// [bufconfig.BreakingConfig], we still return an error. Also, if the roots change, we're
280+
// torched. (Issue #3641)
281+
if len(imageWithConfigs) > len(againstImages) && hasNoUniqueBreakingConfig(imageWithConfigs) {
282+
imageWithConfigs, err = filterImageWithConfigsNotInAgainstImages(imageWithConfigs, againstImages)
283+
if err != nil {
284+
return err
285+
}
286+
} else {
287+
return newInputAgainstImageCountError(len(imageWithConfigs), len(againstImages))
288+
}
276289
}
277290
// We add all check configs (both lint and breaking) as related configs to check if plugins
278291
// have rules configured.
@@ -340,3 +353,91 @@ func validateFlags(flags *flags) error {
340353
}
341354
return nil
342355
}
356+
357+
// hasNoUniqueBreakingConfig iterates through imageWithConfigs and checks to see if there
358+
// are any unique [bufconfig.BreakingConfig]. It returns true if all [bufconfig.BreakingConfig]
359+
// are the same across all the images.
360+
func hasNoUniqueBreakingConfig(imageWithConfigs []bufctl.ImageWithConfig) bool {
361+
var base bufconfig.BreakingConfig
362+
for _, imageWithConfig := range imageWithConfigs {
363+
if base == nil {
364+
base = imageWithConfig.BreakingConfig()
365+
continue
366+
}
367+
if !equalBreakingConfig(base, imageWithConfig.BreakingConfig()) {
368+
return false
369+
}
370+
base = imageWithConfig.BreakingConfig()
371+
}
372+
return true
373+
}
374+
375+
// Checks if the specified [bufconfig.BreakingConfig]s are equal. Returns true if both
376+
// [bufconfig.BreakingConfig] have the same configuration parameters.
377+
func equalBreakingConfig(breakingConfig1, breakingConfig2 bufconfig.BreakingConfig) bool {
378+
if breakingConfig1.Disabled() == breakingConfig2.Disabled() &&
379+
breakingConfig1.FileVersion() == breakingConfig2.FileVersion() &&
380+
slices.Equal(breakingConfig1.UseIDsAndCategories(), breakingConfig2.UseIDsAndCategories()) &&
381+
slices.Equal(breakingConfig1.ExceptIDsAndCategories(), breakingConfig2.ExceptIDsAndCategories()) &&
382+
slices.Equal(breakingConfig1.IgnorePaths(), breakingConfig2.IgnorePaths()) &&
383+
maps.EqualFunc(
384+
breakingConfig1.IgnoreIDOrCategoryToPaths(),
385+
breakingConfig2.IgnoreIDOrCategoryToPaths(),
386+
slices.Equal[[]string],
387+
) &&
388+
breakingConfig1.DisableBuiltin() == breakingConfig2.DisableBuiltin() &&
389+
breakingConfig1.IgnoreUnstablePackages() == breakingConfig2.IgnoreUnstablePackages() {
390+
return true
391+
}
392+
return false
393+
}
394+
395+
// A helper function for filtering out [bufctl.ImageWithConfig]s from [imagesWithConfig]
396+
// if there is no corresponding image in [againstImages]. We determine this based on image
397+
// file path.
398+
//
399+
// This assumes that len(imageWithConfigs) > len(againstImages).
400+
// We also expect that each image in [againstImages] is mapped only once to a single
401+
// imageWithConfig in [imagesWithConfig]. If an againstImage is found, then we don't check
402+
// it again. We also validate that each image in [againstImages] is mapped to an imageWithConfig
403+
// from [imageWithConfigs].
404+
func filterImageWithConfigsNotInAgainstImages(
405+
imageWithConfigs []bufctl.ImageWithConfig,
406+
againstImages []bufimage.Image,
407+
) ([]bufctl.ImageWithConfig, error) {
408+
foundAgainstImageIndices := make(map[int]struct{})
409+
var filteredImageWithConfigs []bufctl.ImageWithConfig
410+
for _, imageWithConfig := range imageWithConfigs {
411+
for _, imageFile := range imageWithConfig.Files() {
412+
var foundImage bufimage.Image
413+
for i, againstImage := range againstImages {
414+
if _, ok := foundAgainstImageIndices[i]; ok {
415+
continue
416+
}
417+
if againstImage.GetFile(imageFile.Path()) != nil {
418+
foundAgainstImageIndices[i] = struct{}{}
419+
foundImage = againstImage
420+
break
421+
}
422+
}
423+
if foundImage != nil {
424+
filteredImageWithConfigs = append(filteredImageWithConfigs, imageWithConfig)
425+
break
426+
}
427+
}
428+
}
429+
// If we are unsuccessful in mapping all againstImages to a unique imageWithConfig, then
430+
// we return the same error message.
431+
if len(foundAgainstImageIndices) != len(againstImages) || len(againstImages) != len(filteredImageWithConfigs) {
432+
return nil, newInputAgainstImageCountError(len(imageWithConfigs), len(againstImages))
433+
}
434+
return filteredImageWithConfigs, nil
435+
}
436+
437+
func newInputAgainstImageCountError(lenImageWithConfigs, lenAgainstImages int) error {
438+
return fmt.Errorf(
439+
"input contained %d images, whereas against contained %d images",
440+
lenImageWithConfigs,
441+
lenAgainstImages,
442+
)
443+
}

private/buf/cmd/buf/testdata/workspace/fail/breaking/other/proto/request.proto

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ syntax = "proto3";
22

33
package request;
44

5-
message Request {}
5+
message Request {
6+
int64 name = 1;
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
syntax = "proto3";
2+
3+
package a.v1;
4+
5+
message A {}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
version: v1
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
version: v1
2+
directories:
3+
- a/proto
4+
- other/proto
5+
- proto
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
version: v1
2+
name: bufbuild.test/workspace/request
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
syntax = "proto3";
2+
3+
package request;
4+
5+
message Request {
6+
string name = 1;
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
version: v1
2+
name: bufbuild.test/workspace/rpc
3+
deps:
4+
- bufbuild.test/workspace/request
5+
breaking:
6+
use:
7+
- PACKAGE
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
syntax = "proto3";
2+
3+
package example;
4+
5+
import "request.proto";
6+
7+
message RPC {
8+
request.Request req = 1;
9+
}

0 commit comments

Comments
 (0)