@@ -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+ }
0 commit comments