github.com/wolfi-dev/wolfictl@v0.16.11/pkg/git/git.go (about)

     1  package git
     2  
     3  import (
     4  	"fmt"
     5  	"log/slog"
     6  	"net/url"
     7  	"os"
     8  	"os/exec"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/chainguard-dev/clog"
    13  
    14  	"github.com/go-git/go-git/v5/plumbing"
    15  	"github.com/go-git/go-git/v5/plumbing/object"
    16  	"github.com/go-git/go-git/v5/plumbing/storer"
    17  	"github.com/go-git/go-git/v5/plumbing/transport"
    18  
    19  	"github.com/go-git/go-git/v5"
    20  	"github.com/wolfi-dev/wolfictl/pkg/stringhelpers"
    21  
    22  	gitHttp "github.com/go-git/go-git/v5/plumbing/transport/http"
    23  )
    24  
    25  func GetGitAuth(gitURL string) (*gitHttp.BasicAuth, error) {
    26  	logger := clog.NewLogger(slog.Default()) // TODO: plumb through context, everywhere
    27  
    28  	parsedURL, err := ParseGitURL(gitURL)
    29  	if err != nil {
    30  		return nil, fmt.Errorf("failed to parse git URL %q: %w", gitURL, err)
    31  	}
    32  
    33  	// Only use GITHUB_TOKEN for github.com URLs
    34  	if parsedURL.Host != "github.com" {
    35  		logger.Warnf("host %q is not github.com, not using GITHUB_TOKEN for authentication", parsedURL.Host)
    36  		return nil, nil
    37  	}
    38  
    39  	gitToken := os.Getenv("GITHUB_TOKEN")
    40  
    41  	if gitToken == "" {
    42  		// If the token is empty, there's no way we can return a usable authentication
    43  		// anyway. Whereas if we return nil, and don't auth, we have a chance at
    44  		// succeeding with access of a public repo.
    45  		return &gitHttp.BasicAuth{}, nil
    46  	}
    47  
    48  	return &gitHttp.BasicAuth{
    49  		Username: "abc123",
    50  		Password: gitToken,
    51  	}, nil
    52  }
    53  
    54  type URL struct {
    55  	Scheme       string
    56  	Host         string
    57  	Organisation string
    58  	Name         string
    59  	RawURL       string
    60  }
    61  
    62  func GetRemoteURLFromDir(dir string) (*URL, error) {
    63  	r, err := git.PlainOpen(dir)
    64  	if err != nil {
    65  		return nil, err
    66  	}
    67  	return GetRemoteURL(r)
    68  }
    69  
    70  func GetRemoteURL(repo *git.Repository) (*URL, error) {
    71  	remote, err := repo.Remote("origin")
    72  	if err != nil {
    73  		return nil, fmt.Errorf("failed to find git origin URL: %w", err)
    74  	}
    75  
    76  	if len(remote.Config().URLs) == 0 {
    77  		return nil, fmt.Errorf("no remote config URLs found for remote origin")
    78  	}
    79  
    80  	return ParseGitURL(remote.Config().URLs[0])
    81  }
    82  
    83  // ParseGitURL returns owner, repo name, errors
    84  func ParseGitURL(rawURL string) (*URL, error) {
    85  	if rawURL == "" {
    86  		return nil, fmt.Errorf("no URL provided")
    87  	}
    88  
    89  	gitURL := &URL{}
    90  
    91  	rawURL = strings.TrimSuffix(rawURL, ".git")
    92  
    93  	// handle git@ kinds of URIs
    94  	if strings.HasPrefix(rawURL, "git@") {
    95  		t := strings.TrimPrefix(rawURL, "git@")
    96  		t = strings.TrimPrefix(t, "/")
    97  		t = strings.TrimPrefix(t, "/")
    98  		t = strings.TrimSuffix(t, "/")
    99  
   100  		arr := stringhelpers.RegexpSplit(t, ":|/")
   101  		if len(arr) >= 3 {
   102  			gitURL.Scheme = "git"
   103  			gitURL.Host = arr[0]
   104  			gitURL.Organisation = arr[1]
   105  			gitURL.Name = arr[len(arr)-1]
   106  			gitURL.RawURL = fmt.Sprintf("https://%s/%s/%s.git", gitURL.Host, gitURL.Organisation, gitURL.Name)
   107  			return gitURL, nil
   108  		}
   109  	}
   110  
   111  	parsedURL, err := url.Parse(rawURL)
   112  	if err != nil {
   113  		return nil, fmt.Errorf("failed to parse git url %s: %w", rawURL, err)
   114  	}
   115  	gitURL.Scheme = parsedURL.Scheme
   116  	if gitURL.Scheme != "https" {
   117  		return nil, fmt.Errorf("unsupported scheme: %v", parsedURL.Scheme)
   118  	}
   119  
   120  	gitURL.Host = parsedURL.Host
   121  	parts := strings.Split(parsedURL.Path, "/")
   122  	if parsedURL.Host == "github.com" {
   123  		if len(parts) < 2 {
   124  			return nil, fmt.Errorf("invalid github path: %s", parsedURL.Path)
   125  		}
   126  		gitURL.Organisation = parts[1]
   127  		gitURL.Name = parts[2]
   128  	}
   129  	gitURL.RawURL = rawURL
   130  
   131  	return gitURL, nil
   132  }
   133  
   134  func GetGitAuthorSignature() *object.Signature {
   135  	gitAuthorName := os.Getenv("GIT_AUTHOR_NAME")
   136  	gitAuthorEmail := os.Getenv("GIT_AUTHOR_EMAIL")
   137  	// override default git config tagger info
   138  	if gitAuthorName != "" && gitAuthorEmail != "" {
   139  		return &object.Signature{
   140  			Name:  gitAuthorName,
   141  			Email: gitAuthorEmail,
   142  			When:  time.Now(),
   143  		}
   144  	}
   145  	return nil
   146  }
   147  
   148  func SetGitSignOptions(repoPath string) error {
   149  	cmd := exec.Command("git", "config", "--local", "commit.gpgsign", "true")
   150  	cmd.Dir = repoPath
   151  	rs, err := cmd.Output()
   152  	if err != nil {
   153  		return fmt.Errorf("failed to set git config gpgsign %q: %w", rs, err)
   154  	}
   155  
   156  	cmd = exec.Command("git", "config", "--local", "gpg.x509.program", "gitsign")
   157  	cmd.Dir = repoPath
   158  	rs, err = cmd.Output()
   159  	if err != nil {
   160  		return fmt.Errorf("failed to set git config gpg.x509.program %q: %w", rs, err)
   161  	}
   162  
   163  	cmd = exec.Command("git", "config", "--local", "gpg.format", "x509")
   164  	cmd.Dir = repoPath
   165  	rs, err = cmd.Output()
   166  	if err != nil {
   167  		return fmt.Errorf("failed to set git config gpg.format %q: %w", rs, err)
   168  	}
   169  
   170  	gitAuthorName := os.Getenv("GIT_AUTHOR_NAME")
   171  	gitAuthorEmail := os.Getenv("GIT_AUTHOR_EMAIL")
   172  	if gitAuthorName == "" || gitAuthorEmail == "" {
   173  		return fmt.Errorf("missing GIT_AUTHOR_NAME and/or GIT_AUTHOR_EMAIL environment variable, please set")
   174  	}
   175  
   176  	cmd = exec.Command("git", "config", "--local", "user.name", gitAuthorName)
   177  	cmd.Dir = repoPath
   178  	rs, err = cmd.Output()
   179  	if err != nil {
   180  		return fmt.Errorf("failed to set git config user.name %q: %w", rs, err)
   181  	}
   182  
   183  	cmd = exec.Command("git", "config", "--local", "user.email", gitAuthorEmail)
   184  	cmd.Dir = repoPath
   185  	rs, err = cmd.Output()
   186  	if err != nil {
   187  		return fmt.Errorf("failed to set git config user.email %q: %w", rs, err)
   188  	}
   189  
   190  	return nil
   191  }
   192  
   193  // TempClone clones the repo using the provided HTTPS URL to a temp directory,
   194  // and returns the path to the temp directory.
   195  //
   196  // If hash is non-empty, the repo will be checked out to that commit hash.
   197  //
   198  // If user authentication is requested, a personal access token will be read in
   199  // from the GITHUB_TOKEN environment variable.
   200  //
   201  // The caller is responsible for cleaning up the temp directory.
   202  func TempClone(gitURL, hash string, useAuth bool) (repoDir string, err error) {
   203  	dir, err := os.MkdirTemp("", "wolfictl-git-clone-*")
   204  	if err != nil {
   205  		return dir, fmt.Errorf("unable to create temp directory for git clone: %w", err)
   206  	}
   207  
   208  	var auth transport.AuthMethod
   209  	if useAuth {
   210  		auth, err = GetGitAuth(gitURL)
   211  		if err != nil {
   212  			return dir, fmt.Errorf("unable to get git auth: %w", err)
   213  		}
   214  	}
   215  
   216  	repo, err := git.PlainClone(dir, false, &git.CloneOptions{
   217  		Auth: auth,
   218  		URL:  gitURL,
   219  	})
   220  	if err != nil {
   221  		return dir, fmt.Errorf("unable to clone repo %q to temp directory: %w", gitURL, err)
   222  	}
   223  
   224  	if hash != "" {
   225  		w, err := repo.Worktree()
   226  		if err != nil {
   227  			return "", fmt.Errorf("unable to get worktree for repo %q: %w", gitURL, err)
   228  		}
   229  		err = w.Checkout(&git.CheckoutOptions{
   230  			Hash: plumbing.NewHash(hash),
   231  		})
   232  		if err != nil {
   233  			return "", fmt.Errorf("unable to checkout hash %q for repo %q: %w", hash, gitURL, err)
   234  		}
   235  	}
   236  
   237  	return dir, nil
   238  }
   239  
   240  // FindForkPoint finds the fork point between the local branch and the upstream
   241  // branch.
   242  //
   243  // The fork point is the commit hash of the latest commit had in common between
   244  // the local branch and the upstream branch.
   245  //
   246  // The local branch is the branch pointed to by the provided branchRef.
   247  //
   248  // The upstream branch is the branch pointed to by the provided upstreamRef.
   249  //
   250  // The caller is responsible for closing the provided repo.
   251  func FindForkPoint(repo *git.Repository, branchRef, upstreamRef *plumbing.Reference) (*plumbing.Hash, error) {
   252  	// Get the commit object for the local branch
   253  	localCommit, err := repo.CommitObject(branchRef.Hash())
   254  	if err != nil {
   255  		return nil, err
   256  	}
   257  
   258  	// Get the commit iterator for the upstream branch
   259  	upstreamIter, err := repo.Log(&git.LogOptions{From: upstreamRef.Hash()})
   260  	if err != nil {
   261  		return nil, err
   262  	}
   263  	defer upstreamIter.Close()
   264  
   265  	// Collect all upstream commit hashes for comparison
   266  	upstreamCommits := make(map[plumbing.Hash]bool)
   267  	err = upstreamIter.ForEach(func(c *object.Commit) error {
   268  		upstreamCommits[c.Hash] = true
   269  		return nil
   270  	})
   271  	if err != nil {
   272  		return nil, err
   273  	}
   274  
   275  	// Now walk through the local branch commits to find where it diverged
   276  	localIter, err := repo.Log(&git.LogOptions{From: localCommit.Hash})
   277  	if err != nil {
   278  		return nil, err
   279  	}
   280  	defer localIter.Close()
   281  
   282  	var forkPoint *plumbing.Hash
   283  	err = localIter.ForEach(func(c *object.Commit) error {
   284  		if _, exists := upstreamCommits[c.Hash]; exists {
   285  			// This commit exists in both histories, so it's a common ancestor and potential fork point
   286  			forkPoint = &c.Hash
   287  			// We stop iterating as we found the most recent common commit
   288  			return storer.ErrStop
   289  		}
   290  		return nil
   291  	})
   292  	if err != nil {
   293  		return nil, err
   294  	}
   295  
   296  	if forkPoint == nil {
   297  		return nil, fmt.Errorf("fork point not found")
   298  	}
   299  
   300  	return forkPoint, nil
   301  }