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 }