diff --git a/private/buf/buffetch/internal/errors.go b/private/buf/buffetch/internal/errors.go index 9ac81aac50..b1a4c0e564 100644 --- a/private/buf/buffetch/internal/errors.go +++ b/private/buf/buffetch/internal/errors.go @@ -48,6 +48,16 @@ func NewCannotSpecifyCommitOrTagWithRefError() error { return errors.New(`cannot specify "commit" or "tag" with "ref"`) } +// NewCannotSpecifyMergeBaseWithOtherGitOptionsError is a fetch error. +func NewCannotSpecifyMergeBaseWithOtherGitOptionsError() error { + return errors.New(`"merge_base" cannot be specified with "branch", "commit", "tag", or "ref"`) +} + +// NewMergeBaseOnlyForLocalGitError is a fetch error. +func NewMergeBaseOnlyForLocalGitError() error { + return errors.New(`"merge_base" is only supported for local git references (e.g. ".git#merge_base=main")`) +} + // NewDepthParseError is a fetch error. func NewDepthParseError(s string) error { return fmt.Errorf(`could not parse "depth" value %q`, s) diff --git a/private/buf/buffetch/internal/git_ref.go b/private/buf/buffetch/internal/git_ref.go index 9fefd70845..7878173347 100644 --- a/private/buf/buffetch/internal/git_ref.go +++ b/private/buf/buffetch/internal/git_ref.go @@ -43,6 +43,7 @@ type gitRef struct { recurseSubmodules bool subDirPath string filter string + gitMergeBase string } func newGitRef( @@ -53,6 +54,7 @@ func newGitRef( recurseSubmodules bool, subDirPath string, filter string, + gitMergeBase string, ) (*gitRef, error) { gitScheme, path, err := getGitSchemeAndPath(format, path) if err != nil { @@ -77,6 +79,7 @@ func newGitRef( depth, subDirPath, filter, + gitMergeBase, ), nil } @@ -89,6 +92,7 @@ func newDirectGitRef( depth uint32, subDirPath string, filter string, + gitMergeBase string, ) *gitRef { return &gitRef{ format: format, @@ -99,6 +103,7 @@ func newDirectGitRef( recurseSubmodules: recurseSubmodules, subDirPath: subDirPath, filter: filter, + gitMergeBase: gitMergeBase, } } @@ -134,6 +139,10 @@ func (r *gitRef) Filter() string { return r.filter } +func (r *gitRef) GitMergeBase() string { + return r.gitMergeBase +} + func (*gitRef) ref() {} func (*gitRef) bucketRef() {} func (*gitRef) gitRef() {} diff --git a/private/buf/buffetch/internal/internal.go b/private/buf/buffetch/internal/internal.go index 9434759da6..1023d819d7 100644 --- a/private/buf/buffetch/internal/internal.go +++ b/private/buf/buffetch/internal/internal.go @@ -209,6 +209,10 @@ type GitRef interface { SubDirPath() string // Filter spec to use, see the --filter option in git rev-list. Filter() string + // GitMergeBase returns the ref to compute the merge-base with (via "git merge-base HEAD "). + // Returns empty string if not set. + // Only supported for local git references. + GitMergeBase() string gitRef() } @@ -221,7 +225,7 @@ func NewGitRef( subDirPath string, filter string, ) (GitRef, error) { - return newGitRef("", path, gitName, depth, recurseSubmodules, subDirPath, filter) + return newGitRef("", path, gitName, depth, recurseSubmodules, subDirPath, filter, "") } // ModuleRef is a module reference. @@ -357,6 +361,7 @@ func NewDirectParsedGitRef( depth uint32, subDirPath string, filter string, + gitMergeBase string, ) ParsedGitRef { return newDirectGitRef( format, @@ -367,6 +372,7 @@ func NewDirectParsedGitRef( depth, subDirPath, filter, + gitMergeBase, ) } @@ -565,6 +571,13 @@ type RawRef struct { // Only set for git formats. // The filter spec to use, see the --filter option in git rev-list. GitFilter string + // Only set for git formats. + // Specifies a branch or ref to compute the git merge-base with, relative to HEAD. + // When set, buf will run "git merge-base HEAD " and use the resulting + // commit as the checkout target. + // Cannot be specified with GitBranch, GitCommitOrTag, or GitRef. + // Only supported for local git references. + GitMergeBase string // Only set for archive formats. ArchiveStripComponents uint32 // Only set for proto file ref format. diff --git a/private/buf/buffetch/internal/reader.go b/private/buf/buffetch/internal/reader.go index fc3ba3b5f1..54c147086c 100644 --- a/private/buf/buffetch/internal/reader.go +++ b/private/buf/buffetch/internal/reader.go @@ -363,6 +363,30 @@ func (r *reader) getGitBucket( if err != nil { return nil, nil, err } + gitName := gitRef.GitName() + if mergeBaseRef := gitRef.GitMergeBase(); mergeBaseRef != "" { + if gitRef.GitScheme() != GitSchemeLocal { + return nil, nil, NewMergeBaseOnlyForLocalGitError() + } + // gitRef.Path() is the normalized path to the git repository (e.g. ".git"). + // We need the directory containing it to run "git merge-base". + localPath := normalpath.Unnormalize(gitRef.Path()) + absPath, err := filepath.Abs(localPath) + if err != nil { + return nil, nil, fmt.Errorf("could not resolve local git path: %w", err) + } + // Use the parent directory if the path is a .git directory, + // otherwise use the path directly (worktree or bare repo). + gitDir := absPath + if filepath.Base(absPath) == ".git" { + gitDir = filepath.Dir(absPath) + } + mergeBaseCommit, err := git.GetMergeBase(ctx, container, gitDir, mergeBaseRef) + if err != nil { + return nil, nil, err + } + gitName = git.NewRefName(mergeBaseCommit) + } readWriteBucket := storagemem.NewReadWriteBucket() if err := r.gitCloner.CloneToBucket( ctx, @@ -371,7 +395,7 @@ func (r *reader) getGitBucket( gitRef.Depth(), readWriteBucket, git.CloneToBucketOptions{ - Name: gitRef.GitName(), + Name: gitName, RecurseSubmodules: gitRef.RecurseSubmodules(), SubDir: gitRef.SubDirPath(), Filter: gitRef.Filter(), diff --git a/private/buf/buffetch/internal/ref_parser.go b/private/buf/buffetch/internal/ref_parser.go index a2c20ebe8e..f77acdbce2 100644 --- a/private/buf/buffetch/internal/ref_parser.go +++ b/private/buf/buffetch/internal/ref_parser.go @@ -142,6 +142,8 @@ func (a *refParser) getRawRef( rawRef.GitCommitOrTag = value case "ref": rawRef.GitRef = value + case "merge_base": + rawRef.GitMergeBase = value case "filter": rawRef.GitFilter = value case "depth": @@ -191,8 +193,8 @@ func (a *refParser) getRawRef( if rawRef.Format == "git" && rawRef.GitDepth == 0 { // Default to 1 rawRef.GitDepth = 1 - if rawRef.GitRef != "" { - // Default to 50 when using ref + if rawRef.GitRef != "" || rawRef.GitMergeBase != "" { + // Default to 50 when using ref or merge_base rawRef.GitDepth = 50 } } @@ -347,8 +349,11 @@ func (a *refParser) validateRawRef( if rawRef.GitRef != "" && rawRef.GitCommitOrTag != "" { return NewCannotSpecifyCommitOrTagWithRefError() } + if rawRef.GitMergeBase != "" && (rawRef.GitBranch != "" || rawRef.GitCommitOrTag != "" || rawRef.GitRef != "") { + return NewCannotSpecifyMergeBaseWithOtherGitOptionsError() + } } else { - if rawRef.GitBranch != "" || rawRef.GitCommitOrTag != "" || rawRef.GitRef != "" || rawRef.GitRecurseSubmodules || rawRef.GitDepth > 0 { + if rawRef.GitBranch != "" || rawRef.GitCommitOrTag != "" || rawRef.GitRef != "" || rawRef.GitMergeBase != "" || rawRef.GitRecurseSubmodules || rawRef.GitDepth > 0 { return NewOptionsInvalidForFormatError(rawRef.Format, displayName, "git options set") } } @@ -522,6 +527,7 @@ func getGitRef( rawRef.GitRecurseSubmodules, rawRef.SubDirPath, rawRef.GitFilter, + rawRef.GitMergeBase, ) } diff --git a/private/buf/buffetch/internal/ref_parser_test.go b/private/buf/buffetch/internal/ref_parser_test.go index c28612e17b..a97061f7b1 100644 --- a/private/buf/buffetch/internal/ref_parser_test.go +++ b/private/buf/buffetch/internal/ref_parser_test.go @@ -15,9 +15,12 @@ package internal import ( + "context" "testing" + "github.com/bufbuild/buf/private/pkg/slogtestext" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetRawPathAndOptionsError(t *testing.T) { @@ -85,3 +88,38 @@ func testGetRawPathAndOptionsError( assert.EqualError(t, err, expectedErr.Error()) }) } + +func TestRefParserGitMergeBaseValidation(t *testing.T) { + t.Parallel() + parser := newRefParser(slogtestext.NewLogger(t), WithGitFormat("git")) + ctx := context.Background() + + t.Run("valid_merge_base", func(t *testing.T) { + t.Parallel() + parsedRef, err := parser.getParsedRef(ctx, "path/to/repo#format=git,merge_base=main", nil) + require.NoError(t, err) + gitRef, ok := parsedRef.(GitRef) + require.True(t, ok, "expected GitRef") + assert.Equal(t, "main", gitRef.GitMergeBase()) + assert.Equal(t, uint32(50), gitRef.Depth()) + assert.Nil(t, gitRef.GitName()) + }) + + t.Run("merge_base_with_branch", func(t *testing.T) { + t.Parallel() + _, err := parser.getParsedRef(ctx, "path/to/repo#format=git,merge_base=main,branch=feature", nil) + assert.EqualError(t, err, NewCannotSpecifyMergeBaseWithOtherGitOptionsError().Error()) + }) + + t.Run("merge_base_with_ref", func(t *testing.T) { + t.Parallel() + _, err := parser.getParsedRef(ctx, "path/to/repo#format=git,merge_base=main,ref=abc123", nil) + assert.EqualError(t, err, NewCannotSpecifyMergeBaseWithOtherGitOptionsError().Error()) + }) + + t.Run("merge_base_with_commit", func(t *testing.T) { + t.Parallel() + _, err := parser.getParsedRef(ctx, "path/to/repo#format=git,merge_base=main,commit=abc123", nil) + assert.EqualError(t, err, NewCannotSpecifyMergeBaseWithOtherGitOptionsError().Error()) + }) +} diff --git a/private/buf/buffetch/ref_parser_test.go b/private/buf/buffetch/ref_parser_test.go index 8b6d73517f..896735d021 100644 --- a/private/buf/buffetch/ref_parser_test.go +++ b/private/buf/buffetch/ref_parser_test.go @@ -257,6 +257,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir.git", ) @@ -271,6 +272,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 40, "", "", + "", ), "path/to/dir.git#depth=40", ) @@ -285,6 +287,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir.git#branch=main", ) @@ -299,6 +302,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "file:///path/to/dir.git#branch=main", ) @@ -313,6 +317,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir.git#tag=v1.0.0", ) @@ -327,6 +332,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "http://hello.com/path/to/dir.git#branch=main", ) @@ -341,6 +347,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "https://hello.com/path/to/dir.git#branch=main", ) @@ -355,6 +362,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "ssh://user@hello.com:path/to/dir.git#branch=main", ) @@ -369,6 +377,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 50, "", "", + "", ), "ssh://user@hello.com:path/to/dir.git#ref=refs/remotes/origin/HEAD", ) @@ -383,6 +392,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 50, "", "", + "", ), "ssh://user@hello.com:path/to/dir.git#ref=refs/remotes/origin/HEAD,branch=main", ) @@ -397,6 +407,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 10, "", "", + "", ), "ssh://user@hello.com:path/to/dir.git#ref=refs/remotes/origin/HEAD,depth=10", ) @@ -411,6 +422,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 10, "", "", + "", ), "ssh://user@hello.com:path/to/dir.git#ref=refs/remotes/origin/HEAD,branch=main,depth=10", ) @@ -425,6 +437,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "foo/bar", "", + "", ), "path/to/dir.git#subdir=foo/bar", ) @@ -439,6 +452,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir.git#subdir=.", ) @@ -453,6 +467,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir.git#subdir=foo/..", ) @@ -467,6 +482,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "git://user@hello.com:path/to/dir.git#branch=main", ) @@ -481,6 +497,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "git://path/to/dir.git#branch=main", ) @@ -495,6 +512,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "subdir", "tree:0", + "", ), "git://path/to/dir.git#branch=main,filter=tree:0,subdir=subdir", ) @@ -842,6 +860,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "/path/to/dir#branch=main,format=git", ) @@ -856,6 +875,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "/path/to/dir#format=git,branch=main/foo", ) @@ -870,6 +890,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir#tag=main/foo,format=git", ) @@ -884,6 +905,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir#format=git,tag=main/foo", ) @@ -898,6 +920,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir#format=git,tag=main/foo,recurse_submodules=true", ) @@ -912,6 +935,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 1, "", "", + "", ), "path/to/dir#format=git,tag=main/foo,recurse_submodules=false", ) @@ -926,6 +950,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 50, "", "", + "", ), "path/to/dir#format=git,ref=refs/remotes/origin/HEAD", ) @@ -940,6 +965,7 @@ func TestGetParsedRefSuccess(t *testing.T) { 10, "", "", + "", ), "path/to/dir#format=git,ref=refs/remotes/origin/HEAD,depth=10", ) diff --git a/private/pkg/git/git.go b/private/pkg/git/git.go index ed47fa01ce..724942b477 100644 --- a/private/pkg/git/git.go +++ b/private/pkg/git/git.go @@ -312,6 +312,31 @@ func GetCurrentHEADGitCommit( return strings.TrimSpace(stdout.String()), nil } +// GetMergeBase returns the best common ancestor commit between HEAD and the +// given ref in the git repository that contains dir, equivalent to running +// "git merge-base HEAD ". Returns the full commit hash with no trailing whitespace. +func GetMergeBase( + ctx context.Context, + envContainer app.EnvContainer, + dir string, + ref string, +) (string, error) { + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + if err := xexec.Run( + ctx, + gitCommand, + xexec.WithArgs("merge-base", "HEAD", ref), + xexec.WithStdout(stdout), + xexec.WithStderr(stderr), + xexec.WithDir(dir), + xexec.WithEnv(app.Environ(envContainer)), + ); err != nil { + return "", fmt.Errorf("failed to compute merge-base between HEAD and %s: %w: %s", ref, err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} + // GetRefsForGitCommitAndRemote returns all refs pointing to a given commit based on the // given remote for the given directory. Querying the remote for refs information requires // passing the environment for permissions.