diff --git a/cmd/buf/internal/command/dep/depupdate/depupdate.go b/cmd/buf/internal/command/dep/depupdate/depupdate.go index 20dc2c428f..61f34a3beb 100644 --- a/cmd/buf/internal/command/dep/depupdate/depupdate.go +++ b/cmd/buf/internal/command/dep/depupdate/depupdate.go @@ -15,10 +15,13 @@ package depupdate import ( + "bytes" "context" "errors" "fmt" "log/slog" + "os" + "slices" "buf.build/go/app/appcmd" "buf.build/go/app/appext" @@ -26,7 +29,9 @@ import ( "github.com/bufbuild/buf/cmd/buf/internal/command/dep/internal" "github.com/bufbuild/buf/private/buf/bufcli" "github.com/bufbuild/buf/private/buf/bufctl" + "github.com/bufbuild/buf/private/bufpkg/bufconfig" "github.com/bufbuild/buf/private/bufpkg/bufmodule" + "github.com/bufbuild/buf/private/bufpkg/bufparse" "github.com/bufbuild/buf/private/pkg/syserror" "github.com/spf13/pflag" ) @@ -110,10 +115,19 @@ func run( if err != nil { return err } - configuredDepModuleKeys, err := internal.ModuleKeysAndTransitiveDepModuleKeysForModuleRefs( + // Apply git branch auto-label overrides for matching dependencies. + // originalRefs maps the full name string of each overridden ref to its original ref, + // so that fallback can use the original hard-coded label instead of the default label. + configuredDepModuleRefs, originalRefs, err := applyGitBranchLabelOverrides(ctx, container, dirPath, configuredDepModuleRefs) + if err != nil { + return err + } + configuredDepModuleKeys, err := resolveModuleRefsWithFallback( ctx, container, + logger, configuredDepModuleRefs, + originalRefs, workspaceDepManager.BufLockFileDigestType(), ) if err != nil { @@ -184,3 +198,159 @@ func run( // Log warnings for users on unused configured deps. return internal.LogUnusedConfiguredDepsForWorkspace(workspace, logger) } + +// applyGitBranchLabelOverrides reads the buf.yaml from dirPath and overrides the +// label on any dep whose full name appears in use_git_branch_as_label. +// +// Returns the modified refs and a map from full name string to the original ref +// for each overridden dependency, so that fallback resolution can use the original +// hard-coded label instead of the default label. +func applyGitBranchLabelOverrides( + ctx context.Context, + container appext.Container, + dirPath string, + refs []bufparse.Ref, +) ([]bufparse.Ref, map[string]bufparse.Ref, error) { + useGitBranchAsLabel, disableLabelForBranch, ok := readGitBranchLabelConfig(dirPath) + if !ok || len(useGitBranchAsLabel) == 0 { + return refs, nil, nil + } + // We only need to determine the branch once, using any matching module name. + // Use the first matching module name to check. + branchName, enabled, err := bufcli.GetGitBranchLabelForModule( + ctx, + container.Logger(), + container, + dirPath, + useGitBranchAsLabel[0], + useGitBranchAsLabel, + disableLabelForBranch, + ) + if err != nil { + return nil, nil, err + } + if !enabled { + return refs, nil, nil + } + result := make([]bufparse.Ref, len(refs)) + originalRefs := make(map[string]bufparse.Ref) + for i, moduleRef := range refs { + if slices.Contains(useGitBranchAsLabel, moduleRef.FullName().String()) { + overriddenRef, err := bufparse.NewRef( + moduleRef.FullName().Registry(), + moduleRef.FullName().Owner(), + moduleRef.FullName().Name(), + branchName, + ) + if err != nil { + return nil, nil, err + } + originalRefs[moduleRef.FullName().String()] = moduleRef + result[i] = overriddenRef + } else { + result[i] = moduleRef + } + } + return result, originalRefs, nil +} + +// moduleRefResolver is a function that resolves module refs to module keys. +// Extracted as a type to allow testing with a mock resolver. +type moduleRefResolver func( + ctx context.Context, + container appext.Container, + moduleRefs []bufparse.Ref, + digestType bufmodule.DigestType, +) ([]bufmodule.ModuleKey, error) + +// resolveModuleRefsWithFallback resolves module refs to module keys. If the +// batch resolution fails (e.g., because a branch label doesn't exist on the BSR), +// it falls back to resolving each ref individually, retrying failed branch-labeled +// refs with their original ref (which may have a hard-coded label from buf.yaml). +// +// originalRefs maps full name strings to the original ref before branch label override. +// If nil, no overrides were applied and fallback is not attempted. +func resolveModuleRefsWithFallback( + ctx context.Context, + container appext.Container, + logger *slog.Logger, + refs []bufparse.Ref, + originalRefs map[string]bufparse.Ref, + digestType bufmodule.DigestType, +) ([]bufmodule.ModuleKey, error) { + return doResolveModuleRefsWithFallback( + ctx, + container, + logger, + refs, + originalRefs, + digestType, + internal.ModuleKeysAndTransitiveDepModuleKeysForModuleRefs, + ) +} + +// doResolveModuleRefsWithFallback contains the core logic, accepting a resolver +// function to allow testing. +func doResolveModuleRefsWithFallback( + ctx context.Context, + container appext.Container, + logger *slog.Logger, + refs []bufparse.Ref, + originalRefs map[string]bufparse.Ref, + digestType bufmodule.DigestType, + resolve moduleRefResolver, +) ([]bufmodule.ModuleKey, error) { + // First, try resolving all refs in a single batch. + moduleKeys, err := resolve(ctx, container, refs, digestType) + if err == nil { + return moduleKeys, nil + } + // If no overrides were applied, the error is genuine. + if len(originalRefs) == 0 { + return nil, err + } + logger.DebugContext(ctx, "batch resolution failed, falling back to per-ref resolution", slog.String("error", err.Error())) + // Fall back to per-ref resolution. + var allModuleKeys []bufmodule.ModuleKey + for _, moduleRef := range refs { + keys, resolveErr := resolve(ctx, container, []bufparse.Ref{moduleRef}, digestType) + if resolveErr == nil { + allModuleKeys = append(allModuleKeys, keys...) + continue + } + // Check if this ref was overridden with a branch label. + fallbackRef, wasOverridden := originalRefs[moduleRef.FullName().String()] + if !wasOverridden { + // Not an overridden ref, the error is genuine. + return nil, resolveErr + } + // Branch-labeled ref failed, retry with the original ref from buf.yaml. + logger.DebugContext( + ctx, + "branch label not found, falling back to original label", + slog.String("module", moduleRef.FullName().String()), + slog.String("branch_label", moduleRef.Ref()), + slog.String("fallback_label", fallbackRef.Ref()), + ) + keys, resolveErr = resolve(ctx, container, []bufparse.Ref{fallbackRef}, digestType) + if resolveErr != nil { + return nil, resolveErr + } + allModuleKeys = append(allModuleKeys, keys...) + } + return allModuleKeys, nil +} + +// readGitBranchLabelConfig reads the buf.yaml from dirPath and returns the +// auto-label configuration. Returns ok=false if the buf.yaml cannot be read. +func readGitBranchLabelConfig(dirPath string) (useGitBranchAsLabel []string, disableLabelForBranch []string, ok bool) { + data, err := os.ReadFile(dirPath + "/buf.yaml") + if err != nil { + return nil, nil, false + } + bufYAMLFile, err := bufconfig.ReadBufYAMLFile(bytes.NewReader(data), "buf.yaml") + if err != nil { + return nil, nil, false + } + return bufYAMLFile.UseGitBranchAsLabel(), bufYAMLFile.DisableLabelForBranch(), true +} diff --git a/cmd/buf/internal/command/dep/depupdate/depupdate_test.go b/cmd/buf/internal/command/dep/depupdate/depupdate_test.go new file mode 100644 index 0000000000..0f345e6de4 --- /dev/null +++ b/cmd/buf/internal/command/dep/depupdate/depupdate_test.go @@ -0,0 +1,241 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package depupdate + +import ( + "context" + "fmt" + "testing" + + "buf.build/go/app/appext" + "github.com/bufbuild/buf/private/bufpkg/bufmodule" + "github.com/bufbuild/buf/private/bufpkg/bufparse" + "github.com/bufbuild/buf/private/pkg/slogtestext" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolveModuleRefsWithFallback_OverriddenAndNonOverridden tests the scenario +// where dependency A is overridden with a branch label (which doesn't exist on BSR) +// and dependency B is not overridden. Both should resolve successfully: A via fallback +// to its original label, B directly. +func TestResolveModuleRefsWithFallback_OverriddenAndNonOverridden(t *testing.T) { + t.Parallel() + ctx := t.Context() + logger := slogtestext.NewLogger(t) + + // A's original ref has a hard-coded label "v1.2.0". + refAOriginal := mustNewRef(t, "buf.build", "acme", "weather", "v1.2.0") + // A is overridden to use branch label "feature/test". + refABranch := mustNewRef(t, "buf.build", "acme", "weather", "feature/test") + // B is not overridden, no label (uses default). + refB := mustNewRef(t, "buf.build", "acme", "petapis", "") + + moduleKeyA := mustNewModuleKey(t, "buf.build", "acme", "weather") + moduleKeyB := mustNewModuleKey(t, "buf.build", "acme", "petapis") + + originalRefs := map[string]bufparse.Ref{ + "buf.build/acme/weather": refAOriginal, + } + + // Mock resolver: "feature/test" label doesn't exist for A, everything else works. + mockResolver := func( + _ context.Context, + _ appext.Container, + refs []bufparse.Ref, + _ bufmodule.DigestType, + ) ([]bufmodule.ModuleKey, error) { + // Batch call with both refs: fail because A's branch label doesn't exist. + if len(refs) > 1 { + for _, ref := range refs { + if ref.Ref() == "feature/test" { + return nil, fmt.Errorf("label %q not found for module %s", ref.Ref(), ref.FullName()) + } + } + } + // Single ref calls. + ref := refs[0] + switch { + case ref.FullName().String() == "buf.build/acme/weather" && ref.Ref() == "feature/test": + return nil, fmt.Errorf("label %q not found for module %s", ref.Ref(), ref.FullName()) + case ref.FullName().String() == "buf.build/acme/weather" && ref.Ref() == "v1.2.0": + return []bufmodule.ModuleKey{moduleKeyA}, nil + case ref.FullName().String() == "buf.build/acme/petapis": + return []bufmodule.ModuleKey{moduleKeyB}, nil + default: + return nil, fmt.Errorf("unexpected ref: %s", ref) + } + } + + keys, err := doResolveModuleRefsWithFallback( + ctx, + nil, // container not used by mock + logger, + []bufparse.Ref{refABranch, refB}, + originalRefs, + bufmodule.DigestTypeB5, + mockResolver, + ) + require.NoError(t, err) + require.Len(t, keys, 2) + assert.Equal(t, "buf.build/acme/weather", keys[0].FullName().String()) + assert.Equal(t, "buf.build/acme/petapis", keys[1].FullName().String()) +} + +// TestResolveModuleRefsWithFallback_BatchSucceeds tests that when the batch +// call succeeds, no fallback is needed. +func TestResolveModuleRefsWithFallback_BatchSucceeds(t *testing.T) { + t.Parallel() + ctx := t.Context() + logger := slogtestext.NewLogger(t) + + refA := mustNewRef(t, "buf.build", "acme", "weather", "feature/test") + refB := mustNewRef(t, "buf.build", "acme", "petapis", "") + + moduleKeyA := mustNewModuleKey(t, "buf.build", "acme", "weather") + moduleKeyB := mustNewModuleKey(t, "buf.build", "acme", "petapis") + + originalRefs := map[string]bufparse.Ref{ + "buf.build/acme/weather": mustNewRef(t, "buf.build", "acme", "weather", "v1.2.0"), + } + + callCount := 0 + mockResolver := func( + _ context.Context, + _ appext.Container, + refs []bufparse.Ref, + _ bufmodule.DigestType, + ) ([]bufmodule.ModuleKey, error) { + callCount++ + return []bufmodule.ModuleKey{moduleKeyA, moduleKeyB}, nil + } + + keys, err := doResolveModuleRefsWithFallback( + ctx, + nil, + logger, + []bufparse.Ref{refA, refB}, + originalRefs, + bufmodule.DigestTypeB5, + mockResolver, + ) + require.NoError(t, err) + require.Len(t, keys, 2) + assert.Equal(t, 1, callCount, "should only call resolver once when batch succeeds") +} + +// TestResolveModuleRefsWithFallback_NonOverriddenFails tests that when a +// non-overridden ref fails, the error is returned immediately. +func TestResolveModuleRefsWithFallback_NonOverriddenFails(t *testing.T) { + t.Parallel() + ctx := t.Context() + logger := slogtestext.NewLogger(t) + + refA := mustNewRef(t, "buf.build", "acme", "weather", "feature/test") + refB := mustNewRef(t, "buf.build", "acme", "petapis", "") + + originalRefs := map[string]bufparse.Ref{ + "buf.build/acme/weather": mustNewRef(t, "buf.build", "acme", "weather", "v1.2.0"), + } + + mockResolver := func( + _ context.Context, + _ appext.Container, + refs []bufparse.Ref, + _ bufmodule.DigestType, + ) ([]bufmodule.ModuleKey, error) { + // Batch always fails. + if len(refs) > 1 { + return nil, fmt.Errorf("batch failed") + } + ref := refs[0] + if ref.FullName().String() == "buf.build/acme/petapis" { + return nil, fmt.Errorf("module not found: %s", ref.FullName()) + } + // A with branch label fails too. + if ref.Ref() == "feature/test" { + return nil, fmt.Errorf("label not found") + } + return []bufmodule.ModuleKey{mustNewModuleKey(t, ref.FullName().Registry(), ref.FullName().Owner(), ref.FullName().Name())}, nil + } + + _, err := doResolveModuleRefsWithFallback( + ctx, + nil, + logger, + []bufparse.Ref{refA, refB}, + originalRefs, + bufmodule.DigestTypeB5, + mockResolver, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "module not found: buf.build/acme/petapis") +} + +// TestResolveModuleRefsWithFallback_NoOverrides tests that when no overrides +// were applied, a batch failure is returned directly without per-ref fallback. +func TestResolveModuleRefsWithFallback_NoOverrides(t *testing.T) { + t.Parallel() + ctx := t.Context() + logger := slogtestext.NewLogger(t) + + refA := mustNewRef(t, "buf.build", "acme", "weather", "") + + callCount := 0 + mockResolver := func( + _ context.Context, + _ appext.Container, + _ []bufparse.Ref, + _ bufmodule.DigestType, + ) ([]bufmodule.ModuleKey, error) { + callCount++ + return nil, fmt.Errorf("not found") + } + + _, err := doResolveModuleRefsWithFallback( + ctx, + nil, + logger, + []bufparse.Ref{refA}, + nil, // no overrides + bufmodule.DigestTypeB5, + mockResolver, + ) + require.Error(t, err) + assert.Equal(t, 1, callCount, "should only call resolver once when no overrides") +} + +func mustNewRef(t *testing.T, registry, owner, name, ref string) bufparse.Ref { + t.Helper() + moduleRef, err := bufparse.NewRef(registry, owner, name, ref) + require.NoError(t, err) + return moduleRef +} + +func mustNewModuleKey(t *testing.T, registry, owner, name string) bufmodule.ModuleKey { + t.Helper() + fullName, err := bufparse.NewFullName(registry, owner, name) + require.NoError(t, err) + moduleKey, err := bufmodule.NewModuleKey( + fullName, + uuid.New(), + func() (bufmodule.Digest, error) { + return nil, fmt.Errorf("digest not implemented in test") + }, + ) + require.NoError(t, err) + return moduleKey +} diff --git a/cmd/buf/internal/command/push/push.go b/cmd/buf/internal/command/push/push.go index 693a2bbbfa..4ae61378a8 100644 --- a/cmd/buf/internal/command/push/push.go +++ b/cmd/buf/internal/command/push/push.go @@ -15,9 +15,11 @@ package push import ( + "bytes" "context" "errors" "fmt" + "os" "slices" "strings" @@ -31,6 +33,7 @@ import ( "github.com/bufbuild/buf/private/buf/buffetch" "github.com/bufbuild/buf/private/buf/bufworkspace" "github.com/bufbuild/buf/private/bufpkg/bufanalysis" + "github.com/bufbuild/buf/private/bufpkg/bufconfig" "github.com/bufbuild/buf/private/bufpkg/bufmodule" "github.com/bufbuild/buf/private/pkg/git" "github.com/bufbuild/buf/private/pkg/syserror" @@ -257,6 +260,17 @@ func run( if flags.ExcludeUnnamed { uploadOptions = append(uploadOptions, bufmodule.UploadWithExcludeUnnamed()) } + // Auto-label: if use_git_branch_as_label is configured in buf.yaml and any module + // being pushed matches the list, add the current git branch as a label. + if workspace.IsV2() { + branchLabelOption, err := getGitBranchLabelUploadOption(ctx, container, workspace) + if err != nil { + return err + } + if branchLabelOption != nil { + uploadOptions = append(uploadOptions, branchLabelOption) + } + } commits, err := uploader.Upload(ctx, workspace, uploadOptions...) if err != nil { @@ -561,3 +575,58 @@ func getLabelUploadOption(flags *flags) bufmodule.UploadOption { } return nil } + +// getGitBranchLabelUploadOption reads the buf.yaml for the workspace's source +// directory and returns an UploadWithLabels option if auto-label is enabled for +// any of the modules being pushed. +func getGitBranchLabelUploadOption( + ctx context.Context, + container appext.Container, + workspace bufworkspace.Workspace, +) (bufmodule.UploadOption, error) { + source, err := bufcli.GetInputValue(container, "", ".") + if err != nil { + return nil, err + } + useGitBranchAsLabel, disableLabelForBranch, ok := readGitBranchLabelConfig(source) + if !ok || len(useGitBranchAsLabel) == 0 { + return nil, nil + } + // Check if any target module's name matches the use_git_branch_as_label list. + for _, module := range bufmodule.ModuleSetTargetModules(workspace) { + fullName := module.FullName() + if fullName == nil { + continue + } + branchName, enabled, err := bufcli.GetGitBranchLabelForModule( + ctx, + container.Logger(), + container, + source, + fullName.String(), + useGitBranchAsLabel, + disableLabelForBranch, + ) + if err != nil { + return nil, err + } + if enabled { + return bufmodule.UploadWithLabels(branchName), nil + } + } + return nil, nil +} + +// readGitBranchLabelConfig reads the buf.yaml from dirPath and returns the +// auto-label configuration. Returns ok=false if the buf.yaml cannot be read. +func readGitBranchLabelConfig(dirPath string) (useGitBranchAsLabel []string, disableLabelForBranch []string, ok bool) { + data, err := os.ReadFile(dirPath + "/buf.yaml") + if err != nil { + return nil, nil, false + } + bufYAMLFile, err := bufconfig.ReadBufYAMLFile(bytes.NewReader(data), "buf.yaml") + if err != nil { + return nil, nil, false + } + return bufYAMLFile.UseGitBranchAsLabel(), bufYAMLFile.DisableLabelForBranch(), true +} diff --git a/private/buf/bufcli/env.go b/private/buf/bufcli/env.go index c3a2245628..2d77bc0ff4 100644 --- a/private/buf/bufcli/env.go +++ b/private/buf/bufcli/env.go @@ -38,6 +38,10 @@ const ( // at a per-file level. copyToInMemoryEnvKey = "BUF_BETA_COPY_FILES_TO_MEMORY" + // useGitBranchAsLabelEnvKey can be set to "OFF" (case-insensitive) to disable + // auto-label behavior even when use_git_branch_as_label is configured in buf.yaml. + useGitBranchAsLabelEnvKey = "BUF_USE_GIT_BRANCH_AS_LABEL" + // This should only be used for testing. This is not part of Buf's API, and should // never be documented or part of Buf's contract. legacyFederationRegistryEnvKey = "BUF_TESTING_LEGACY_FEDERATION_REGISTRY" diff --git a/private/buf/bufcli/git_branch_label.go b/private/buf/bufcli/git_branch_label.go new file mode 100644 index 0000000000..4c7b2f3b24 --- /dev/null +++ b/private/buf/bufcli/git_branch_label.go @@ -0,0 +1,63 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bufcli + +import ( + "context" + "log/slog" + "slices" + "strings" + + "buf.build/go/app" + "github.com/bufbuild/buf/private/pkg/git" +) + +// GetGitBranchLabelForModule returns the current git branch name if auto-label +// behavior is enabled for the given module name. +// +// Returns ("", false, nil) if auto-label is not enabled for this module, if the +// environment variable BUF_USE_GIT_BRANCH_AS_LABEL is set to "OFF", if the directory +// is not a git repository, or if the current branch is in the disable list. +func GetGitBranchLabelForModule( + ctx context.Context, + logger *slog.Logger, + envContainer app.EnvContainer, + dir string, + moduleName string, + useGitBranchAsLabel []string, + disableLabelForBranch []string, +) (string, bool, error) { + if len(useGitBranchAsLabel) == 0 { + return "", false, nil + } + if !slices.Contains(useGitBranchAsLabel, moduleName) { + return "", false, nil + } + if strings.EqualFold(envContainer.Env(useGitBranchAsLabelEnvKey), "off") { + return "", false, nil + } + branch, err := git.GetCurrentBranch(ctx, envContainer, dir) + if err != nil { + logger.WarnContext(ctx, "not in a git repository, skipping auto-label", slog.String("error", err.Error())) + return "", false, nil + } + if slices.Contains(disableLabelForBranch, branch) { + return "", false, nil + } + // BSR labels do not work with go get or npm SDKs if they contain "/" characters. + // Convert to "_". + label := strings.ReplaceAll(branch, "/", "_") + return label, true, nil +} diff --git a/private/buf/bufcli/git_branch_label_test.go b/private/buf/bufcli/git_branch_label_test.go new file mode 100644 index 0000000000..2f2ef719c1 --- /dev/null +++ b/private/buf/bufcli/git_branch_label_test.go @@ -0,0 +1,207 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bufcli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "buf.build/go/app" + "buf.build/go/standard/xos/xexec" + "github.com/bufbuild/buf/private/pkg/slogtestext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetGitBranchLabelForModule(t *testing.T) { + t.Parallel() + ctx := t.Context() + logger := slogtestext.NewLogger(t) + + t.Run("disabled_when_list_is_empty", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + branch, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, t.TempDir(), + "buf.build/acme/weather", + nil, + nil, + ) + require.NoError(t, err) + assert.False(t, enabled) + assert.Empty(t, branch) + }) + + t.Run("disabled_when_module_not_in_list", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + branch, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, t.TempDir(), + "buf.build/acme/other", + []string{"buf.build/acme/weather"}, + []string{"main"}, + ) + require.NoError(t, err) + assert.False(t, enabled) + assert.Empty(t, branch) + }) + + t.Run("returns_branch_when_module_matches", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + repoDir := createTestGitRepo(ctx, t, envContainer, "feature/new-api") + label, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, repoDir, + "buf.build/acme/weather", + []string{"buf.build/acme/weather"}, + []string{"main", "master"}, + ) + require.NoError(t, err) + assert.True(t, enabled) + // "/" in branch names is converted to "_" for BSR label compatibility. + assert.Equal(t, "feature_new-api", label) + }) + + t.Run("branch_without_slash_unchanged", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + repoDir := createTestGitRepo(ctx, t, envContainer, "my-feature") + label, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, repoDir, + "buf.build/acme/weather", + []string{"buf.build/acme/weather"}, + []string{"main", "master"}, + ) + require.NoError(t, err) + assert.True(t, enabled) + assert.Equal(t, "my-feature", label) + }) + + t.Run("disabled_when_on_disabled_branch", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + repoDir := createTestGitRepo(ctx, t, envContainer, "main") + branch, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, repoDir, + "buf.build/acme/weather", + []string{"buf.build/acme/weather"}, + []string{"main", "master"}, + ) + require.NoError(t, err) + assert.False(t, enabled) + assert.Empty(t, branch) + }) + + t.Run("disabled_when_not_git_repo", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + branch, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, t.TempDir(), + "buf.build/acme/weather", + []string{"buf.build/acme/weather"}, + []string{"main"}, + ) + require.NoError(t, err) + assert.False(t, enabled) + assert.Empty(t, branch) + }) + + t.Run("disabled_when_env_var_off", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + envContainer = app.NewEnvContainerWithOverrides( + envContainer, + map[string]string{"BUF_USE_GIT_BRANCH_AS_LABEL": "OFF"}, + ) + repoDir := createTestGitRepo(ctx, t, envContainer, "feature/test") + branch, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, repoDir, + "buf.build/acme/weather", + []string{"buf.build/acme/weather"}, + []string{"main"}, + ) + require.NoError(t, err) + assert.False(t, enabled) + assert.Empty(t, branch) + }) + + t.Run("env_var_off_case_insensitive", func(t *testing.T) { + t.Parallel() + envContainer, err := app.NewEnvContainerForOS() + require.NoError(t, err) + envContainer = app.NewEnvContainerWithOverrides( + envContainer, + map[string]string{"BUF_USE_GIT_BRANCH_AS_LABEL": "off"}, + ) + repoDir := createTestGitRepo(ctx, t, envContainer, "feature/test") + branch, enabled, err := GetGitBranchLabelForModule( + ctx, logger, envContainer, repoDir, + "buf.build/acme/weather", + []string{"buf.build/acme/weather"}, + []string{"main"}, + ) + require.NoError(t, err) + assert.False(t, enabled) + assert.Empty(t, branch) + }) +} + +// createTestGitRepo creates a temporary git repo on the given branch with an +// initial commit. Returns the repo directory path. +func createTestGitRepo( + ctx context.Context, + t *testing.T, + envContainer app.EnvContainer, + branchName string, +) string { + t.Helper() + repoDir := t.TempDir() + environ := app.Environ(envContainer) + runGit(ctx, t, repoDir, environ, "init") + runGit(ctx, t, repoDir, environ, "config", "user.email", "tests@buf.build") + runGit(ctx, t, repoDir, environ, "config", "user.name", "Buf go tests") + runGit(ctx, t, repoDir, environ, "checkout", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "test.txt"), []byte("hello"), 0600)) + runGit(ctx, t, repoDir, environ, "add", "test.txt") + runGit(ctx, t, repoDir, environ, "commit", "-m", "initial commit") + if branchName != "main" { + runGit(ctx, t, repoDir, environ, "checkout", "-b", branchName) + } + return repoDir +} + +func runGit(ctx context.Context, t *testing.T, dir string, environ []string, args ...string) { + t.Helper() + stderr := bytes.NewBuffer(nil) + err := xexec.Run( + ctx, + "git", + xexec.WithArgs(args...), + xexec.WithDir(dir), + xexec.WithEnv(environ), + xexec.WithStderr(stderr), + ) + require.NoError(t, err, "git %v failed: %s", args, stderr.String()) +} diff --git a/private/bufpkg/bufconfig/buf_yaml_file.go b/private/bufpkg/bufconfig/buf_yaml_file.go index 38f3420701..7fa26a0aa1 100644 --- a/private/bufpkg/bufconfig/buf_yaml_file.go +++ b/private/bufpkg/bufconfig/buf_yaml_file.go @@ -116,6 +116,19 @@ type BufYAMLFile interface { // The ModuleRefs in this list will be unique by FullName. // Sorted by FullName. ConfiguredDepModuleRefs() []bufparse.Ref + // UseGitBranchAsLabel returns the list of module names for which the current + // git branch should be used as the BSR label during push and dep update. + // + // For v1 buf.yaml files, this will always return nil. + UseGitBranchAsLabel() []string + // DisableLabelForBranch returns the list of git branch names for which + // auto-label behavior is disabled (i.e., the default label is used instead). + // + // If UseGitBranchAsLabel is set but DisableLabelForBranch is not, the default + // value is ["main", "master"]. + // + // For v1 buf.yaml files, this will always return nil. + DisableLabelForBranch() []string //IncludeDocsLink specifies whether a top-level comment with a link to our public docs // should be included at the top of the buf.yaml file. IncludeDocsLink() bool @@ -147,6 +160,8 @@ func NewBufYAMLFile( pluginConfigs, policyConfigs, configuredDepModuleRefs, + nil, // useGitBranchAsLabel + nil, // disableLabelForBranch bufYAMLFileOptions.includeDocsLink, ) } @@ -262,6 +277,8 @@ type bufYAMLFile struct { pluginConfigs []PluginConfig policyConfigs []PolicyConfig configuredDepModuleRefs []bufparse.Ref + useGitBranchAsLabel []string + disableLabelForBranch []string includeDocsLink bool } @@ -274,6 +291,8 @@ func newBufYAMLFile( pluginConfigs []PluginConfig, policyConfigs []PolicyConfig, configuredDepModuleRefs []bufparse.Ref, + useGitBranchAsLabel []string, + disableLabelForBranch []string, includeDocsLink bool, ) (*bufYAMLFile, error) { if (fileVersion == FileVersionV1Beta1 || fileVersion == FileVersionV1) && len(moduleConfigs) > 1 { @@ -330,6 +349,8 @@ func newBufYAMLFile( pluginConfigs: pluginConfigs, policyConfigs: policyConfigs, configuredDepModuleRefs: configuredDepModuleRefs, + useGitBranchAsLabel: useGitBranchAsLabel, + disableLabelForBranch: disableLabelForBranch, includeDocsLink: includeDocsLink, }, nil } @@ -370,6 +391,14 @@ func (c *bufYAMLFile) ConfiguredDepModuleRefs() []bufparse.Ref { return slices.Clone(c.configuredDepModuleRefs) } +func (c *bufYAMLFile) UseGitBranchAsLabel() []string { + return slices.Clone(c.useGitBranchAsLabel) +} + +func (c *bufYAMLFile) DisableLabelForBranch() []string { + return slices.Clone(c.disableLabelForBranch) +} + func (c *bufYAMLFile) IncludeDocsLink() bool { return c.includeDocsLink } @@ -470,6 +499,8 @@ func readBufYAMLFile( nil, nil, configuredDepModuleRefs, + nil, // useGitBranchAsLabel - v1 only + nil, // disableLabelForBranch - v1 only includeDocsLink, ) case FileVersionV2: @@ -677,6 +708,13 @@ func readBufYAMLFile( if err != nil { return nil, err } + useGitBranchAsLabel := externalBufYAMLFile.UseGitBranchAsLabel + disableLabelForBranch := externalBufYAMLFile.DisableLabelForBranch + // If use_git_branch_as_label is set but disable_label_for_branch is not, + // default to disabling auto-label for main and master branches. + if len(useGitBranchAsLabel) > 0 && len(disableLabelForBranch) == 0 { + disableLabelForBranch = []string{"main", "master"} + } return newBufYAMLFile( fileVersion, objectData, @@ -686,6 +724,8 @@ func readBufYAMLFile( pluginConfigs, policyConfigs, configuredDepModuleRefs, + useGitBranchAsLabel, + disableLabelForBranch, includeDocsLink, ) default: @@ -893,6 +933,8 @@ func writeBufYAMLFile(writer io.Writer, bufYAMLFile BufYAMLFile) error { externalPolicies = append(externalPolicies, externalPolicy) } externalBufYAMLFile.Policies = externalPolicies + externalBufYAMLFile.UseGitBranchAsLabel = bufYAMLFile.UseGitBranchAsLabel() + externalBufYAMLFile.DisableLabelForBranch = bufYAMLFile.DisableLabelForBranch() data, err := encoding.MarshalYAML(&externalBufYAMLFile) if err != nil { @@ -1298,14 +1340,16 @@ type externalBufYAMLFileV1Beta1V1 struct { // Note that the lint and breaking ids/categories DID change between versions, make // sure to deal with this when parsing what to set as defaults, or how to interpret categories. type externalBufYAMLFileV2 struct { - Version string `json:"version,omitempty" yaml:"version,omitempty"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Modules []externalBufYAMLFileModuleV2 `json:"modules,omitempty" yaml:"modules,omitempty"` - Deps []string `json:"deps,omitempty" yaml:"deps,omitempty"` - Lint externalBufYAMLFileLintV2 `json:"lint" yaml:"lint,omitempty"` - Breaking externalBufYAMLFileBreakingV1Beta1V1V2 `json:"breaking" yaml:"breaking,omitempty"` - Plugins []externalBufYAMLFilePluginV2 `json:"plugins,omitempty" yaml:"plugins,omitempty"` - Policies []externalBufYAMLFilePolicyV2 `json:"policies,omitempty" yaml:"policies,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Modules []externalBufYAMLFileModuleV2 `json:"modules,omitempty" yaml:"modules,omitempty"` + Deps []string `json:"deps,omitempty" yaml:"deps,omitempty"` + Lint externalBufYAMLFileLintV2 `json:"lint" yaml:"lint,omitempty"` + Breaking externalBufYAMLFileBreakingV1Beta1V1V2 `json:"breaking" yaml:"breaking,omitempty"` + Plugins []externalBufYAMLFilePluginV2 `json:"plugins,omitempty" yaml:"plugins,omitempty"` + Policies []externalBufYAMLFilePolicyV2 `json:"policies,omitempty" yaml:"policies,omitempty"` + UseGitBranchAsLabel []string `json:"use_git_branch_as_label,omitempty" yaml:"use_git_branch_as_label,omitempty"` + DisableLabelForBranch []string `json:"disable_label_for_branch,omitempty" yaml:"disable_label_for_branch,omitempty"` } // externalBufYAMLFileModuleV2 represents a single module configuration within a v2 buf.yaml file. diff --git a/private/bufpkg/bufconfig/buf_yaml_file_test.go b/private/bufpkg/bufconfig/buf_yaml_file_test.go index 8513c30b14..ee60121f2d 100644 --- a/private/bufpkg/bufconfig/buf_yaml_file_test.go +++ b/private/bufpkg/bufconfig/buf_yaml_file_test.go @@ -507,6 +507,85 @@ policies: ) } +func TestReadWriteBufYAMLFileGitBranchAsLabel(t *testing.T) { + t.Parallel() + + // Round-trip with both fields set. + testReadWriteBufYAMLFileRoundTrip( + t, + // input + `version: v2 +use_git_branch_as_label: + - buf.build/acme/weather + - buf.build/acme/petapis +disable_label_for_branch: + - main + - production +`, + // expected output + `version: v2 +use_git_branch_as_label: + - buf.build/acme/weather + - buf.build/acme/petapis +disable_label_for_branch: + - main + - production +`, + ) + + // Round-trip with only use_git_branch_as_label set (disable_label_for_branch + // defaults to ["main", "master"] during parsing but is not written if it + // matches the default — actually it IS written since we store it). + testReadWriteBufYAMLFileRoundTrip( + t, + // input + `version: v2 +use_git_branch_as_label: + - buf.build/acme/weather +`, + // expected output (default disable_label_for_branch is populated) + `version: v2 +use_git_branch_as_label: + - buf.build/acme/weather +disable_label_for_branch: + - main + - master +`, + ) + + // Verify field values are accessible. + bufYAMLFile := testReadBufYAMLFile( + t, + `version: v2 +use_git_branch_as_label: + - buf.build/acme/weather +disable_label_for_branch: + - main + - staging +`, + ) + require.Equal(t, []string{"buf.build/acme/weather"}, bufYAMLFile.UseGitBranchAsLabel()) + require.Equal(t, []string{"main", "staging"}, bufYAMLFile.DisableLabelForBranch()) + + // Without the field, both should be nil. + bufYAMLFile = testReadBufYAMLFile( + t, + `version: v2 +`, + ) + require.Nil(t, bufYAMLFile.UseGitBranchAsLabel()) + require.Nil(t, bufYAMLFile.DisableLabelForBranch()) + + // V1 files should always return nil. + bufYAMLFile = testReadBufYAMLFile( + t, + `version: v1 +`, + ) + require.Nil(t, bufYAMLFile.UseGitBranchAsLabel()) + require.Nil(t, bufYAMLFile.DisableLabelForBranch()) +} + func TestBufYAMLFileLintDisabled(t *testing.T) { t.Parallel() diff --git a/private/pkg/git/git.go b/private/pkg/git/git.go index ed47fa01ce..6703aec3e9 100644 --- a/private/pkg/git/git.go +++ b/private/pkg/git/git.go @@ -384,6 +384,34 @@ func IsValidRef( return nil } +// GetCurrentBranch returns the name of the current git branch for the given directory. +// +// Returns an error if the directory is not a valid git checkout or if HEAD is detached. +func GetCurrentBranch( + ctx context.Context, + envContainer app.EnvContainer, + dir string, +) (string, error) { + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + if err := xexec.Run( + ctx, + gitCommand, + xexec.WithArgs("rev-parse", "--abbrev-ref", "HEAD"), + xexec.WithStdout(stdout), + xexec.WithStderr(stderr), + xexec.WithDir(dir), + xexec.WithEnv(app.Environ(envContainer)), + ); err != nil { + return "", fmt.Errorf("failed to get current branch: %w: %s", err, stderr.String()) + } + branch := strings.TrimSpace(stdout.String()) + if branch == "" || branch == "HEAD" { + return "", errors.New("HEAD is detached, not on a branch") + } + return branch, nil +} + // ReadFileAtRef will read the file at path rolled back to the given ref, if // it exists at that ref. // diff --git a/private/pkg/git/git_test.go b/private/pkg/git/git_test.go index faf3305847..4a57bd1942 100644 --- a/private/pkg/git/git_test.go +++ b/private/pkg/git/git_test.go @@ -470,6 +470,72 @@ func createGitDirs( return originPath, workPath } +func TestGetCurrentBranch(t *testing.T) { + t.Parallel() + ctx := t.Context() + container, err := app.NewContainerForOS() + require.NoError(t, err) + + t.Run("returns_branch_name", func(t *testing.T) { + t.Parallel() + repoDir := t.TempDir() + runCommand(ctx, t, container, "git", "-C", repoDir, "init") + runCommand(ctx, t, container, "git", "-C", repoDir, "config", "user.email", "tests@buf.build") + runCommand(ctx, t, container, "git", "-C", repoDir, "config", "user.name", "Buf go tests") + runCommand(ctx, t, container, "git", "-C", repoDir, "checkout", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "test.txt"), []byte("hello"), 0600)) + runCommand(ctx, t, container, "git", "-C", repoDir, "add", "test.txt") + runCommand(ctx, t, container, "git", "-C", repoDir, "commit", "-m", "initial commit") + + branch, err := GetCurrentBranch(ctx, container, repoDir) + require.NoError(t, err) + assert.Equal(t, "main", branch) + }) + + t.Run("returns_feature_branch", func(t *testing.T) { + t.Parallel() + repoDir := t.TempDir() + runCommand(ctx, t, container, "git", "-C", repoDir, "init") + runCommand(ctx, t, container, "git", "-C", repoDir, "config", "user.email", "tests@buf.build") + runCommand(ctx, t, container, "git", "-C", repoDir, "config", "user.name", "Buf go tests") + runCommand(ctx, t, container, "git", "-C", repoDir, "checkout", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "test.txt"), []byte("hello"), 0600)) + runCommand(ctx, t, container, "git", "-C", repoDir, "add", "test.txt") + runCommand(ctx, t, container, "git", "-C", repoDir, "commit", "-m", "initial commit") + runCommand(ctx, t, container, "git", "-C", repoDir, "checkout", "-b", "feature/my-feature") + + branch, err := GetCurrentBranch(ctx, container, repoDir) + require.NoError(t, err) + assert.Equal(t, "feature/my-feature", branch) + }) + + t.Run("errors_on_detached_head", func(t *testing.T) { + t.Parallel() + repoDir := t.TempDir() + runCommand(ctx, t, container, "git", "-C", repoDir, "init") + runCommand(ctx, t, container, "git", "-C", repoDir, "config", "user.email", "tests@buf.build") + runCommand(ctx, t, container, "git", "-C", repoDir, "config", "user.name", "Buf go tests") + runCommand(ctx, t, container, "git", "-C", repoDir, "checkout", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "test.txt"), []byte("hello"), 0600)) + runCommand(ctx, t, container, "git", "-C", repoDir, "add", "test.txt") + runCommand(ctx, t, container, "git", "-C", repoDir, "commit", "-m", "initial commit") + // Detach HEAD by checking out the commit directly. + runCommand(ctx, t, container, "git", "-C", repoDir, "checkout", "--detach", "HEAD") + + _, err := GetCurrentBranch(ctx, container, repoDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "HEAD is detached") + }) + + t.Run("errors_on_non_git_dir", func(t *testing.T) { + t.Parallel() + nonGitDir := t.TempDir() + + _, err := GetCurrentBranch(ctx, container, nonGitDir) + require.Error(t, err) + }) +} + func runCommand( ctx context.Context, t *testing.T,