blob: 822017f7553dcb337dfdb3f4340cba1e371f8775 [file] [log] [blame] [edit]
// Copyright 2020-2024 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 git
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"net/url"
"os/exec"
"regexp"
"strings"
"github.com/bufbuild/buf/private/pkg/app"
"github.com/bufbuild/buf/private/pkg/command"
)
const (
bitBucketHostname = "bitbucket"
githubHostname = "github"
gitlabHostname = "gitlab"
gitSuffix = ".git"
githubRemoteURLFormat = "https://%s%s/commit/%s"
gitlabRemoteURLFormat = "https://%s%s/commit/%s"
bitBucketRemoteURLFormat = "https://%s%s/commits/%s"
// remoteKindUnknown is a remote to a unknown Git source.
remoteKindUnknown remoteKind = iota + 1
// RemoteKindGitHub is a remote to a GitHub Git source.
remoteKindGitHub
// RemoteKindGitLab is a remote to a GitLab Git source.
remoteKindGitLab
// RemoteKindBitBucket is a remote to a BitBucket Git source.
remoteKindBitBucket
)
// remoteKind is the kind of remote based on Git source (e.g. GitHub, GitLab, BitBucket, etc.)
type remoteKind int
type remote struct {
name string
kind remoteKind
hostname string
repositoryPath string
headBranch string
}
func (r *remote) Name() string {
return r.name
}
func (r *remote) Hostname() string {
return r.hostname
}
func (r *remote) RepositoryPath() string {
return r.repositoryPath
}
func (r *remote) HEADBranch() string {
return r.headBranch
}
func (r *remote) isRemote() {}
func (r *remote) SourceControlURL(gitCommitSha string) string {
switch r.kind {
case remoteKindBitBucket:
return fmt.Sprintf(
bitBucketRemoteURLFormat,
r.Hostname(),
r.RepositoryPath(),
gitCommitSha,
)
case remoteKindGitHub:
return fmt.Sprintf(
githubRemoteURLFormat,
r.Hostname(),
r.RepositoryPath(),
gitCommitSha,
)
case remoteKindGitLab:
return fmt.Sprintf(
gitlabRemoteURLFormat,
r.Hostname(),
r.RepositoryPath(),
gitCommitSha,
)
}
// Unknown remote kind, we return an empty URL.
return ""
}
func newRemote(
name string,
kind remoteKind,
hostname string,
repositoryPath string,
headBranch string,
) *remote {
return &remote{
name: name,
kind: kind,
hostname: hostname,
repositoryPath: repositoryPath,
headBranch: headBranch,
}
}
func getRemote(
ctx context.Context,
runner command.Runner,
envContainer app.EnvContainer,
dir string,
name string,
) (*remote, error) {
if err := validateRemoteExists(ctx, runner, envContainer, dir, name); err != nil {
return nil, err
}
hostname, repositoryPath, err := getRemoteURLMetadata(
ctx,
runner,
envContainer,
dir,
name,
)
if err != nil {
return nil, fmt.Errorf("failed to get remote URL metadata: %w", err)
}
headBranch, err := getRemoteHEADBranch(ctx, runner, envContainer, dir, name)
if err != nil {
return nil, fmt.Errorf("failed to get remote HEAD branch: %w", err)
}
return newRemote(
name,
getRemoteKindFromHostname(hostname),
hostname,
repositoryPath,
headBranch,
), nil
}
func validateRemoteExists(
ctx context.Context,
runner command.Runner,
envContainer app.EnvContainer,
dir string,
name string,
) error {
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
if err := runner.Run(
ctx,
gitCommand,
command.RunWithArgs("ls-remote", "--exit-code", name),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
command.RunWithEnv(app.EnvironMap(envContainer)),
); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr.ProcessState.ExitCode() == 128 {
return fmt.Errorf("remote %s: %w", name, ErrRemoteNotFound)
}
}
return err
}
return nil
}
func getRemoteURLMetadata(
ctx context.Context,
runner command.Runner,
envContainer app.EnvContainer,
dir string,
remote string,
) (string, string, error) {
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
if err := runner.Run(
ctx,
gitCommand,
// We use `git config --get remote.<remote>.url` instead of `git remote get-url
// since it is more specific to the checkout.
command.RunWithArgs("config", "--get", fmt.Sprintf("remote.%s.url", remote)),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
command.RunWithEnv(app.EnvironMap(envContainer)),
); err != nil {
return "", "", err
}
hostname, repositoryPath := parseRawRemoteURL(strings.TrimSpace(stdout.String()))
return hostname, repositoryPath, nil
}
func parseRawRemoteURL(rawURL string) (string, string) {
parsed, err := url.Parse(rawURL)
if err != nil {
// Attempt to check if this is a scp-style URL.
parsed = parseSCPLikeURL(rawURL)
}
// If we are unable to parse host and path information from the URL, then we return
// no information.
if parsed == nil {
return "", ""
}
return parsed.Hostname(), strings.TrimSuffix(parsed.Path, gitSuffix)
}
func parseSCPLikeURL(rawURL string) *url.URL {
// https://git-scm.com/docs/git-clone#_git_urls
// An alternative scp-like syntax may also be used with the ssh protocol:
// [<user>@]<host>:/<path-to-git-repo>
//
// To parse this, we use a regexp from cmd/go/internal/vcs/vcs.go
// https://cs.opensource.google/go/go/+/refs/tags/go1.21.10:src/cmd/go/internal/vcs/vcs.go;l=281-283
scpSyntaxRegexp := regexp.MustCompile(`^(\w+)@([\w.-]+):(.*)$`)
if match := scpSyntaxRegexp.FindStringSubmatch(rawURL); match != nil {
// The host and path are the only relevant URL components to our use case.
return &url.URL{
Host: match[2],
Path: "/" + match[3], // scp-like URLs do not have the leading slash
}
}
return nil
}
// getRemoteHEADBranch returns the HEAD branch based on the given remote and given
// directory. Querying the remote for the HEAD branch requires passing the
// environment for permissions.
func getRemoteHEADBranch(
ctx context.Context,
runner command.Runner,
envContainer app.EnvContainer,
dir string,
remote string,
) (string, error) {
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
if err := runner.Run(
ctx,
gitCommand,
command.RunWithArgs("remote", "show", remote),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
command.RunWithEnv(app.EnvironMap(envContainer)),
); err != nil {
return "", err
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if branch, isHEADBranch := strings.CutPrefix(line, "HEAD branch:"); isHEADBranch {
return strings.TrimSpace(branch), nil
}
}
return "", errors.New("no HEAD branch information found")
}
func getRemoteKindFromHostname(hostname string) remoteKind {
if strings.Contains(hostname, bitBucketHostname) {
return remoteKindBitBucket
}
if strings.Contains(hostname, githubHostname) {
return remoteKindGitHub
}
if strings.Contains(hostname, gitlabHostname) {
return remoteKindGitLab
}
return remoteKindUnknown
}